Date picker
Sets a date, a range, a date and time, or a time of day.
Examples
Stored as 02:30, 24-hour.
Granularity
datedefault
range
datetime
Stored as 02:30, 24-hour.
time
Sizes
sm
defaultdefault
lg
States
empty
hover
focus
half a range
invalid
disabled
Not a time. Try 9:30 or 21:00
unreadable time
Usage
When to use it
- Use
- A date that could be any day in the next or last few months: a snooze, a scheduled eval.
- Use
- A period for a report or chart, where the length matters.
- Use
- A time of day for something that repeats, like nightly quiet hours.
- Not for
- A date of birth or anything years away; type it. Use Input.
- Not for
- A handful of fixed periods (24h, 7d, 30d). Use Segmented control.
- Not for
- A duration, like a timeout; use a number input with a unit. Use Input.
Rules
9 rules
- Must
- Use mode="range" when the length matters; never two separate pickers. Because two pickers let the end come before the start and lose the day count the range shows.
- Must
- Write a range or datetime value only on a finished pick: both ends, a preset or Set. Because the first click writes half a range, and a query runs with no end date.
- Must
- Pass disabledDays for days that cannot be picked; never validate after the pick. Because the person learns the date was never allowed only after picking it; disabled days are skipped by the arrow keys.
- Should
- Let the person type a time and keep step at 15 or 30 for the list.
- Should
- Name a preset 'Last 7 days' and count it to include today.
- Must
- Put a labelled picker in a Field with a FieldLabel. Because the trigger is a button that takes its name from the FieldLabel, so a p leaves it unnamed.
- Should
- Set clearable on an optional date so it can go back to empty.
- Never
- Add date-fns, dayjs or react-day-picker; this picker needs only Date and Intl. Because the picker works on plain Date and Intl, so a library adds bundle weight and a second idea of week start and locale.
- Should
- Expect one month under 640px; do not force numberOfMonths={2} in a narrow sheet.
Accessibility
6 checks
- The trigger is a button named by the Field label plus the value, with aria-expanded and aria-invalid.
- The calendar is an ARIA grid; days are named by their full date and carry aria-selected and aria-current="date".
- Arrows move by day and week, Page Up and Down by month, with Shift by year, Home and End by week; left and right swap in right to left.
- Escape closes and drops the draft; the value stays as it was.
- The range caption announces what to pick next and the day count.
- An unreadable time shows its reason in visible text under the field.
Edge cases
12 edge cases
- Long German date in 320px
- Truncates in the trigger; the day count drops out first.
- One-day range
- Reads "1 day", singular.
- No value, clearable
- The placeholder shows; the popup ends with Clear.
- 160px wide range
- The trigger fills the width; the popup drops to one month with wrapping presets.
- Right to left
- Carets mirror; the range band rounds its inline ends.
- Zoom 200%
- Day cells stay 32px; the popup scrolls inside the available height.
- Text spacing
- Two-letter weekdays and two-digit days still fit.
- Date not allowed
- Keeps the value, turns invalid and says how to fix it.
- Same day clicked twice in range mode
- Makes a one-day range.
- Inside a dialog
- Escape closes the calendar first, then the dialog.
- Forced colors
- Selected ends draw a 2px border; the band takes Highlight; today keeps its underline.
- Reduced motion
- The popup opens without a transition; month changes are cuts.
API
14 props
granularity'date' | 'datetime' | 'time'default'date'mode'single' | 'range'default'single'value, defaultValue, onValueChangeDate | null, DateRange, "HH:mm" | nullpresets{label, range}[]disabledDays(date) => boolean | {before, after}weekStartsOn0 | 1 | 2 | 3 | 4 | 5 | 6default0localestringdefault'en-US'formatIntl.DateTimeFormatOptionsdefaultSep 18, 2026hourCycle12 | 24default24step, min, max5 | 10 | 15 | 30 | 60, "HH:mm"default15namestringclearablebooleandefaultfalsesize'sm' | 'default' | 'lg'default'default'align'start' | 'center' | 'end'default'start'
Tokens
23 tokens
--line-control- oklch(var(--ink-c) / 0.16)
--line-frame- oklch(var(--ink-c) / var(--line-frame-alpha))
--accent-wash- var(--surface-selected)
--accent-mark- var(--ink)
--line-focus- 2px
--focus-halo- 0 0 0 3px oklch(var(--ink-c) / 0.08)
--border-focus- var(--ink)
--focus-offset- 2px
--surface-page- var(--paper)
--radius-soft- 6px
--elev-overlay- 0 0 0 1px var(--line-frame), 0 12px 24px -12px oklch(var(--text-on-field-c) / var(--shadow-alpha))
--dur-base- 160ms
--gutter-sm- 16px
--control- 32px
--surface-hover- oklch(var(--ink-c) / 0.05)
--dur-fast- 120ms
--button-primary- var(--ink)
--text-inverse- var(--paper)
--surface-pressed- oklch(var(--ink-c) / 0.08)
--text-3- oklch(var(--ink-c) / 0.66)
--text-disabled- oklch(var(--ink-c) / 0.40)
--type-label-sm- 400 12px/16px var(--font-sans)
--text-2- oklch(var(--ink-c) / 0.80)