Date Picker
A typeable single-date field with a calendar panel — Intl-driven formatting and parsing, a complete keyboard grid, and refusals that keep what you typed.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/date-picker.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "DatePicker" component: a typeable text
field plus a calendar panel. lucide-react for icons, Intl.DateTimeFormat for
every string, no date library and no popover library.
Contract
- forwardRef<HTMLInputElement> — this is a form control, so the ref, and every
native prop that is not listed below, land on the <input>. className styles
the field's outer box instead.
- Props extend Omit<React.InputHTMLAttributes<HTMLInputElement>,
"value" | "defaultValue" | "onChange" | "min" | "max" | "type" | "readOnly">
— readOnly is omitted on purpose, because `disabled` is the one inert state
and a half-inert field would still accept panel picks — and add:
value?: Date | null (controlled) / defaultValue?: Date | null (uncontrolled),
onChange?: (date: Date | null) => void,
min?: Date, max?: Date (inclusive, compared by calendar day),
disabledDates?: (date: Date) => boolean,
weekStartsOn?: 0..6 (default 1), locale?: string (default "en-US"),
clearable?: boolean (default true), align?: "start" | "end" (default
"start"), disabled?: boolean, name?: string.
- name renders a hidden input carrying a local "YYYY-MM-DD" string, so the form
posts an ISO date instead of the locale text the user reads. Build that string
from getFullYear/getMonth/getDate — never toISOString(), which shifts the day
for anyone east or west of UTC.
Behavior
- One Intl.DateTimeFormat({year:"numeric",month:"2-digit",day:"2-digit"}) is the
single source of truth for the field: formatToParts() gives the locale's part
order, which builds the placeholder ("MM/DD/YYYY", "DD.MM.YYYY", …) AND drives
the parser, so hint, printed value and parse can never drift apart.
- Parsing: take the digit groups out of the typed text, map them onto that part
order, accept a 2-digit year as 20xx, reject anything that is not 2 or 4
digits, then round-trip the constructed Date — "02/30/2026" silently becomes
March 2nd otherwise, and a rollover is a typo, not a date.
- Draft vs value: state holds `draft: string | null` (null = mirror the
committed value). Every keystroke sets the draft; a *complete* parse that is
in range commits immediately and moves the grid to that month. A half-typed
string stays silent — invalidity is a verdict for blur and Enter, not for
every keystroke.
- Refusals never destroy input: an unparseable string ("Use the MM/DD/YYYY
format.") or a real day the rules forbid ("Saturday, July 18, 2026 is not
available.") sets aria-invalid and an error message while the raw text stays
in the field. A value handed in through props that violates min/max/
disabledDates is flagged the same way — the field never pretends it accepted
something it did not.
- Compose with form libraries instead of fighting them: read the consumer's
aria-invalid / aria-describedby out of props and OR / concatenate them with
the internal ones.
- Keyboard, field: ArrowDown (or the calendar button) opens the panel and moves
focus onto a day cell; Enter settles the draft and, only when the panel is
open, closes it and preventDefault()s so picking a date never submits the
surrounding form; Escape closes the panel, stopPropagation() so a surrounding
dialog does not also close, and returns focus to the field.
- Keyboard, grid: Arrow keys ±1 day / ±7 days, Home / End to the bounds of the
focused week, PageUp / PageDown ±1 month, Shift + PageUp / PageDown ±1 year
(clamp the day: Jan 31 → Feb 28/29, Feb 29 → Feb 28), Enter / Space select.
Every move hops over unavailable days by walking up to ~62 days in the travel
direction and giving up if nothing is selectable.
- Roving tabindex: exactly one cell is tabbable — the focused day, else the
selection, else today, else the first selectable day of the month, else the
1st. The grid is one tab stop; Tab leaves it, arrows move inside it.
- ARIA: the panel is role="dialog" with its own aria-label (non-modal, so no
focus trap); the trigger carries aria-haspopup="dialog", aria-expanded and
aria-controls (only while open — a dangling id is an ARIA error); the month
is role="grid" labelled with its caption, weekday names are role="columnheader"
with a full-name aria-label, days are role="gridcell" wrappers with
aria-selected around a <button> with aria-current="date" for today.
- Unavailable days get aria-disabled + a "…, unavailable" label and stay in the
tree, struck through — a day that vanishes cannot be told apart from a day
that does not exist. Every guard lives in the handler, so nothing relies on
pointer-events for correctness.
- Never the native disabled attribute on anything the user may be standing on:
the ✕ unmounts itself the moment it clears (focus the input *first*), the
panel's Clear dies the instant it succeeds, the month arrows die at the
min/max edge, Today dies when today is out of range. All of them use
aria-disabled + an early return.
- disabled makes the field readOnly + aria-disabled — focusable and readable,
not ripped out of the tab order — and derives the panel's open state as
`open && !disabled` so going inert can never leave a live panel over a dead
field.
- A polite live region (role="status", visually hidden) announces the focused
day while arrowing and the new caption when the month moves without a focused
day; it is always mounted, because a live region that appears together with
its text is not announced.
- Pointer, inside the control: on the wrapper that holds the field and the
panel, a pointerdown that did NOT land on something natively focusable
(input/select/textarea/button/a[href]/[tabindex]) is a press on the control's
own surface — the field's padding, the panel's padding, the gap between two
day cells. preventDefault() it, which suppresses the compatibility mouse event
that would otherwise blur to <body>; on the field's surface also focus the
field, so the caret keeps its position. Without this an open panel loses its
keyboard owner on a stray press: Escape is bound to the root and the arrows to
the grid, so neither would ever see the key again. Keep the handler off the
root itself, or the error text below the field stops being selectable.
- Closing: pointerdown outside the whole control closes it — the trigger is
excluded from that listener so the toggle cannot immediately reopen itself;
focusout closes on a real Tab-out only (relatedTarget outside the root) and
does not steal focus back. Both listeners are added only while open and
removed on close/unmount.
- Focus after a month swap: cells are re-created, so raise a ref flag in the
handler and move DOM focus in an effect after the commit; when the target cell
is already on screen, focus it synchronously instead and skip the flag.
- "Today" comes from useSyncExternalStore with a server snapshot of null, so SSR
renders no today marker and React fills it in after hydration — no mismatch,
no mounted flag.
Rendering & styling
- Semantic tokens only: field border + bg-background, bg-muted when inert,
border-destructive + text-destructive for the invalid state, panel bg-popover
/ text-popover-foreground with a border and shadow, selected day bg-primary /
text-primary-foreground, today ring-1 ring-primary, hover bg-accent /
text-accent-foreground, muted-foreground for outside and unavailable days.
- cn() merges every className; focus-within ring on the field shell and
focus-visible:ring-2 ring-ring on every button, select and day cell.
- Motion is decoration: a 140ms panel fade-in and a 180ms month cross-fade, both
behind motion-reduce:[animation:none], plus motion-reduce:transition-none on
colour transitions. With motion off the panel simply appears.
- Six week rows always, so the panel keeps one height across months.
Customization levers
- Density: the day cell is size-9 with gap-1 — drop to size-8 for a compact
field, and the header/footer follow because nothing is measured in JS.
- Sub-blocks are independent: delete the footer (Today / Clear), swap the
month + year <select>s for a static caption plus arrows, or replace them with
a shadcn Select (the native selects are chosen for keyboard/AT completeness
and inherit system option styling).
- Panel placement: align="start" | "end" flips the anchor edge; to escape a
clipping ancestor, mount the same panel in a portal — nothing in the logic
assumes it is a sibling.
- Locale: pass one locale string, and the pattern, month names, weekday letters
and typing order all follow. The parser reads ASCII digit groups, so for a
non-Latin numbering system swap parseDate for one that maps that system's
digits.
- Tokens: recolour the selected day to var(--chart-1) for a calendar that has to
match a chart, or switch today's marker from a ring to a dot under the number.
- Weekend / holiday rules belong in disabledDates; min/max stay for the hard
window. Both feed the same refusal path, so the message and the struck-through
cell come for free.Concepts
- Two doors, one value — typing and picking are not two features but two entrances to the same commit: a parsed keystroke moves the grid, a picked day reformats the field, and
onChangefires once either way. - Draft vs value — the field keeps the raw string while it is being edited and only mirrors the committed value once it settles, which is what lets a refusal say "that day is not available" without deleting the four digits you just typed.
- Refusal is a state, not a silence — out-of-range typing, an impossible date like
02/30, and even avalueprop that breaks the rules all land onaria-invalidplus a spoken reason, instead of being rounded, rolled over or quietly dropped. - Roving tabindex — one Tab stop for a 42-cell grid: exactly one day is tabbable and arrows move within it, with unavailable days kept in the tree (struck through,
aria-disabled) so they can be perceived rather than mysteriously missing. - aria-disabled over disabled — every control here can go inert while the user is standing on it (the ✕ that clears itself, the month arrow that hits
min), and the native attribute would blur it to<body>; the guard lives in the handler instead. - Announce what changes silently — moving the focused day or paging the month changes nothing a screen reader would read on its own, so a permanently mounted polite region speaks the focused date and the new caption.
Model Selector
An LLM model picker where every option carries its context window, per-million input/output rate, capability set and whether your key can actually call it — and those fields drive the filtering.
Input Group
A composable affix shell for one field — static text, icon slots and interactive trailing controls flush inside a single border, with size, invalid and disabled flowing down to every slot.