Inputs

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { CalendarDays, ChevronLeft, ChevronRight, X } from "lucide-react"
import { cn } from "@/lib/utils"

/** Panel entrance + month swap. React 19 hoisted <style> — no Tailwind config edits. */
const KEYFRAMES = `@keyframes dp-panel-in{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:none}}
@keyframes dp-month-in{from{opacity:0}to{opacity:1}}`

/** Always six rows so the panel keeps one height across months. */
const WEEK_ROWS = 6
const DAYS_PER_WEEK = 7
/** How far arrow keys keep looking for a selectable day before giving up. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/date-picker.json

Prompt

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 onChange fires 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 a value prop that breaks the rules all land on aria-invalid plus 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.

On This Page