Birthday Field
A date-of-birth input as three typed fields (day, month, year) that auto-advance, validate the real date and show age.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/birthday-field.json'use client'
import { useId, useRef, useState } from 'react'
import './birthday-field.css'
export interface BirthdayFieldProps {
onValueChange?: (iso: string | null) => void
minAge?: number
order?: 'dmy' | 'mdy'
today?: Date
fill?: 'dark' | 'light'
className?: string
}
const MON = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']
/** Three-part date-of-birth input with validation and age. */
export function BirthdayField({ onValueChange, minAge = 0, order = 'dmy', today, fill = 'dark', className }: BirthdayFieldProps) {
const [d, setD] = useState('')
const [m, setM] = useState('')
const [y, setY] = useState('')
const refs = { d: useRef<HTMLInputElement>(null), m: useRef<HTMLInputElement>(null), y: useRef<HTMLInputElement>(null) }
const id = useId()
const seq: ('d' | 'm' | 'y')[] = order === 'dmy' ? ['d', 'm', 'y'] : ['m', 'd', 'y']
const next = (k: 'd' | 'm' | 'y') => { const i = seq.indexOf(k); refs[seq[i + 1]]?.current?.focus() }
const mi = /^\d+$/.test(m) ? Number(m) : MON.indexOf(m.slice(0, 3).toLowerCase()) + 1
const di = Number(d), yi = Number(y)
const complete = d && m && y.length === 4
const date = complete ? new Date(yi, mi - 1, di) : null
const valid = !!date && mi >= 1 && mi <= 12 && date.getDate() === di && date.getMonth() === mi - 1
const now = today ?? new Date()
const age = valid && date ? now.getFullYear() - yi - (now < new Date(now.getFullYear(), mi - 1, di) ? 1 : 0) : null
const err = !complete ? '' : !valid ? (mi < 1 || mi > 12 ? 'That month doesn’t exist.' : `${MON[mi - 1]?.[0].toUpperCase()}${MON[mi - 1]?.slice(1)} doesn’t have ${di} days${mi === 2 ? ` in ${yi}` : ''}.`) : age !== null && age < 0 ? 'That date is in the future.' : age !== null && age > 120 ? 'Check the year.' : age !== null && age < minAge ? `You need to be at least ${minAge}.` : ''
const report = (ok: boolean) => onValueChange?.(ok ? `${yi}-${String(mi).padStart(2, '0')}-${String(di).padStart(2, '0')}` : null)
const field = (k: 'd' | 'm' | 'y') => {
const conf = { d: { l: 'Day', v: d, set: setD, ph: 'DD', len: 2 }, m: { l: 'Month', v: m, set: setM, ph: 'MM', len: 3 }, y: { l: 'Year', v: y, set: setY, ph: 'YYYY', len: 4 } }[k]
return (
<div key={k} className="sd-bdf__f">
<label htmlFor={`${id}-${k}`}>{conf.l}</label>
<input ref={refs[k]} id={`${id}-${k}`} inputMode={k === 'm' ? 'text' : 'numeric'} autoComplete={k === 'd' ? 'bday-day' : k === 'm' ? 'bday-month' : 'bday-year'} placeholder={conf.ph} maxLength={conf.len} value={conf.v} aria-invalid={Boolean(err)} aria-describedby={`${id}-msg`}
onChange={(e) => {
const v = k === 'm' ? e.target.value.replace(/[^0-9a-zA-Z]/g, '') : e.target.value.replace(/\D/g, '')
conf.set(v)
if ((k === 'd' && (v.length === 2 || Number(v) > 3)) || (k === 'm' && ((/^\d+$/.test(v) && (v.length === 2 || Number(v) > 1)) || v.length === 3)) || (k === 'y' && v.length === 4)) next(k)
}}
onKeyDown={(e) => { if (e.key === 'Backspace' && !conf.v) { const i = seq.indexOf(k); refs[seq[i - 1]]?.current?.focus() } }}
onBlur={() => report(valid && !err)} />
</div>
)
}
return (
<fieldset className={['sd-bdf', className].filter(Boolean).join(' ')} data-fill={fill}>
<legend>Date of birth</legend>
<div className="sd-bdf__row">{seq.map(field)}</div>
<p id={`${id}-msg`} className="sd-bdf__msg" aria-live="polite"><span key={err + String(age)}>{err ? <b>{err}</b> : age !== null ? `You’re ${age}.` : 'For example 14 03 1992 or 14 Mar 1992'}</span></p>
</fieldset>
)
}.sd-bdf { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-bdf[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-bdf button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-bdf :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-bdf .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-bdf { width: min(100%, 300px); border: 0; margin: 0; padding: 16px; border-radius: 20px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); }
.sd-bdf legend { float: left; width: 100%; padding: 0; margin-bottom: 10px; font-size: 14px; font-weight: 600; }
.sd-bdf__row { display: grid; grid-template-columns: 1fr 1fr 1.5fr; gap: 8px; clear: both; }
.sd-bdf__f label { display: block; font-size: 11.5px; color: var(--mute); margin: 0 0 4px 2px; }
.sd-bdf__f input { width: 100%; height: 44px; padding: 0 10px; border: 0; border-radius: 11px; outline: 0; background: var(--bg); color: var(--fg); font: 500 16px/1 ui-sans-serif, system-ui; font-variant-numeric: tabular-nums; box-shadow: inset 0 0 0 1px var(--line); transition: box-shadow 150ms; }
.sd-bdf__f input:focus { box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-bdf__f input[aria-invalid='true'] { box-shadow: inset 0 0 0 1.5px var(--fg); background: var(--soft); }
.sd-bdf__msg { min-height: 20px; margin: 10px 0 0 2px; font-size: 12.5px; color: var(--mute); }
.sd-bdf__msg b { color: var(--fg); }
.sd-bdf__msg span { display: inline-block; animation: sd-bdf-in 200ms ease; }
@keyframes sd-bdf-in { from { opacity: 0; transform: translateY(3px); } }
@media (prefers-reduced-motion: reduce) { .sd-bdf *, .sd-bdf *::before, .sd-bdf *::after { animation: none !important; transition: none !important; } }Overview
Separate fields avoid the scroll-through-decades date picker. Each auto-advances when complete, month accepts “3” or “Mar”, and invalid dates such as 31 April are caught with a plain message.
Once valid, it shows the age, and can enforce a minimum age.
When to use
- Sign-up, age gates, insurance and health forms.
When not to use
- Picking near dates like bookings: use a calendar.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/birthday-field.json$
npx shadcn@latest add https://sundaeui.dev/r/birthday-field.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/birthday-field.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/birthday-field.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy birthday-field.tsx and birthday-field.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { BirthdayField } from '@/components/sundae/birthday-field'
<BirthdayField />Props
| Prop | Type | Default |
|---|---|---|
onValueChangeCalled with YYYY-MM-DD when valid. | (iso: string | null) => void | — |
minAgeMinimum age required. | number | — |
orderField order. | 'dmy' | 'mdy' | 'dmy' |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- A fieldset with a legend; each field is labelled and uses inputmode="numeric".
- Errors are linked with aria-describedby and announced.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-bdf; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Birthday Field free?
- Yes. Birthday Field is free for personal and commercial projects, with full source on this page.
- How do I install Birthday Field?
- Run npx shadcn@latest add https://sundaeui.dev/r/birthday-field.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Birthday Field support keyboard and screen readers?
- A fieldset with a legend; each field is labelled and uses inputmode="numeric". Errors are linked with aria-describedby and announced.
- Does Birthday Field respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →Password Strength
Forms · RefinedOTP Field
Forms · Refined- Pro
Onboarding Flow
Forms · Refined - Pro
Wheel Picker
Forms · Refined Phone Input
Forms · RefinedUnit Input
Forms · Refined