Date Picker
A date picker built on Calendar and Base UI Popover — a button trigger or a typed-input anchor (`anchor="input"`), single, range and multiple selection with a full field model (label, validation, clearable), presets, the calendar's markers/booked/caption APIs, a consolidated date-time Apply panel, locale-aware labels and on-color support.
Playground
Installation
pnpm add @tessinaui/ui react-day-pickerUsage
import { DatePicker, createRangePresets } from "@tessinaui/ui";{/* Single date with the field model */}
<DatePicker
mode="single"
label="Departure"
required
clearable
selected={date}
onSelect={setDate}
errorMessage={error}
/>
{/* Date range with presets sidebar */}
<DatePicker
mode="range"
selected={range}
onSelect={setRange}
presets={createRangePresets()}
numberOfMonths={2}
/>
{/* Typed input — type the date, or open the calendar button */}
<DatePicker
mode="single"
anchor="input"
label="Departure"
selected={date}
onSelect={setDate}
/>When to use
- DatePicker — picking a date whose calendar context matters (day of week, distance from today, availability around it), from a compact trigger.
- Calendar — the same grid inline on the page, no popover. Everything DatePicker forwards (markers, booked days, selection variants, the date-time panel) also works there directly.
- Plain
Fields — memorable, known dates (date of birth on a form): GOV.UK's research is blunt that a calendar hurts there. If you keep a picker for it, use thecaptionLayout="dropdown"recipe below rather than making someone page back 400 months. - TimePicker — time alone; the built-in date-time panel covers date + time together.
- Select / Combobox — choosing among a handful of known dates (delivery slots) rather than any date.
anchor="input"— forms where people already know the date and would rather type it (an invoice date, a start date). The calendar stays one Tab away. Single dates only; ranges keep the button.
Format expectations belong in supportingText or the label — never placeholder-only (it disappears the moment a value exists). The input anchor puts the locale's format there by default.
Examples
Default
A single date picker with a calendar icon trigger.
Typed input
anchor="input" turns the field into a text input. Type the date in the locale's order — slashes, dashes, dots or spaces all work, and 2026-06-24 does too — and it's read when you press Enter or leave the field; nothing is masked while you type. The calendar button beside it is its own tab stop and the only way to open the popup. The format sits in the helper row until you set supportingText. Text that can't be read, or a day outside the bounds, shows an error and leaves the value alone — try 24.06.2026 in the first field, then 12/31/2025.
Labels and helper text
The input-family field model: visible label (top or start), optional marker, supporting text — all wired into the trigger's accessible name and description.
Validation
required marks the trigger and the calendar (the selection can't be emptied); errorMessage replaces the supporting text, implies the error intent and is announced politely. intent="warning" paints the boundary without the error semantics.
States
disabled, readOnly (focusable, popup stays closed) and isLoading (read-only semantics + aria-busy + spinner in place of the calendar icon).
Clearable
An in-trigger × clears the value, returns focus to the trigger, and answers Escape on the closed trigger. Follows Field's clear-button conventions, tab stop included.
Range
Range selection with two months side by side. A fresh range closes the popover after the end date; adjusting one endpoint of an existing range keeps it open for further browsing.
Range limits
min / max bound the number of nights; excludeDisabled resets a range that would swallow a disabled day.
Multiple
Multiple selection stays open across picks and summarises the trigger ("3 dates selected"); formatValue overrides the summary.
Presets
Preset shortcuts in a left sidebar (or a top strip). Picking one commits and closes — unless an Apply footer is present, in which case presets only set the selection (preview-style) and Apply commits.
Date & time
The calendar's own Apply panel (one implementation for Calendar and DatePicker): start/end date and time fields, optional timezone select, localized labels. Apply reports a CalendarApplyData and closes the popover.
Date of birth
captionLayout="dropdown" + bounds turns paging into month/year menus — the far-past navigation recipe.
Markers and legend
The calendar's markers (intent dots that extend day accessible names), showLegend, and the auto-hiding Today button — all through the popover.
Booked and blocked days
unavailableDates renders reachable-but-taken days (struck through, focusable, announced, unselectable); disabledDates removes days from reach entirely.
Selection variants
The calendar's selection inks: outline (habit-tracker ring) and line (hotel-style underline range).
On-color
tone="on-color" (set directly or inherited from a Surface) swaps the trigger onto the shared ink-well recipe; the popover itself stays a standard floating surface, per the family rule.
Locale and RTL
locale drives the trigger label (via Intl.DateTimeFormat, range labels via formatRange), the calendar, and the panel; dir="rtl" defaults the locale to Arabic. When dir is unset nothing is stamped — the component no longer forces ltr.
Sizes
Calendar cell density across sm, md, and lg — trigger, chips, panel fields and popup all follow. Trigger heights land on 32/40/48px on desktop; on phones the §5 touch floor raises sm and md to 44px while lg keeps its 48px, so the smaller two deliberately look alike below md — open one to see the cell density differ.
Loading
DatePickerSkeleton for content that isn't there yet; isLoading for a known value that's busy.
API Reference
Selection
| Prop | Type | Default | Description |
|---|---|---|---|
mode | "single" | "range" | "multiple" | "single" | Selection mode; selected/onSelect types follow it |
selected | Date | DateRange | Date[] | — | Controlled selection value |
onSelect | (value) => void | — | Called when selection changes (react-day-picker's extra args pass through) |
formatValue | (selected) => string | — | Override the trigger label for the current selection |
shouldCloseOnSelect | boolean | mode-aware | Default: single closes on pick, range closes when a fresh pick completes, multiple stays open; always suppressed while showDateTime/footer provide a commit step |
Field
| Prop | Type | Default | Description |
|---|---|---|---|
label | ReactNode | — | Visible label, associated with the trigger; the trigger's accessible name reads label + current value |
labelPlacement | "top" | "start" | "top" | Label position |
labelWidth | "sm" | "md" | "lg" | "xl" | "auto" | CSS length | "md" | Width of the start-label column, so sibling fields in a form share one control column — sm 6rem · md 8rem · lg 11rem · xl 14rem. Labels wrap, never truncate. Only applies with labelPlacement="start", from sm up |
visuallyHiddenLabel | boolean | false | Keep the label for AT only |
optional | boolean | false | "(optional)" marker (mutually exclusive with required) |
supportingText | ReactNode | — | Helper line under the trigger, in aria-describedby |
errorMessage | ReactNode | — | REPLACES supportingText, implies intent="error", announced politely |
intent | "none" | "error" | "warning" | "success" | "info" | "none" | Boundary/ring color on the outline trigger |
required | boolean | false | Star + sr-only "(required)" in the label name (button anchor) or aria-required on the input (input anchor), + react-day-picker required (selection can't be emptied) |
disabled | boolean | false | Disables the trigger. Day matchers here are deprecated — see disabledDates |
readOnly | boolean | false | Focusable, popup stays closed, intent paint suppressed (readOnly ⊃ disabled ⊃ error) |
isLoading | boolean | false | Read-only semantics + aria-busy + spinner |
clearable | boolean | false | × button (with a value); Escape on the closed trigger also clears |
onClear | () => void | — | After a clear |
tone | "default" | "on-color" | context | Ink-well recipe on colored surfaces; inherited from Surface |
Trigger
| Prop | Type | Default | Description |
|---|---|---|---|
anchor | "button" | "input" | "button" | input: a text field you type the date into, with a calendar button as the only popup entry. Single mode only — range and multiple keep the button (dev warning) |
placeholder | string | "Pick a date" · none | Button anchor: the label shown with nothing selected. Input anchor: the input's placeholder, empty by default — the format lives in the helper row, never placeholder-only |
parseInput | (text: string) => Date | null | built-in | Input anchor: replaces the lenient locale-order parser; return null to reject. Bounds and disabled days are still enforced after it |
triggerVariant | ButtonProps["variant"] | "outline" | Button anchor: outline reads as a field well; others are plain buttons |
triggerSize | ButtonTextSize | follows size | Button anchor: trigger size override |
triggerClassName | string | — | Extra classes for the trigger — the field shell under the input anchor (e.g. "w-56") |
How the input anchor reads a date. Fields go in the locale's order (MM/DD/YYYY en-US, DD.MM.YYYY de-DE, YYYY/MM/DD ja-JP) — a month/day swap is never guessed. A four-digit first field is read as year-month-day in any locale. Two-digit years pivot at 30 (26 → 2026, 30 → 1930); three-digit years are rejected as typos. The field is always Gregorian, and it reads the locale's own digits and punctuation, so it can always read back what it shows.
Calendar options
| Prop | Type | Default | Description |
|---|---|---|---|
size | "sm" | "md" | "lg" | "md" | Calendar cell scale; trigger/chips/fields follow (32/40/48px triggers) |
numberOfMonths | number | 1 | Months in the popover (collapses to 1 below 640px) |
showWeekNumber · showOutsideDays · fixedWeeks | boolean | — | Display passthroughs |
fromDate / toDate | Date | — | Day-accurate selectable bounds |
disabledDates | Matcher | Matcher[] | — | Days that cannot be selected (react-day-picker matchers) |
unavailableDates | Matcher | Matcher[] | — | Reachable-but-taken days (struck through, focusable, guarded) |
defaultMonth | Date | today | Opening month when nothing is selected; a selection always wins |
locale | string | "en-US" ("ar" at dir="rtl") | BCP-47 tag for trigger label, calendar and panel |
strings | Partial<DatePickerStrings> | — | Override any UI string (calendar strings + dialogLabel*, clearLabel, datesSelected, and the input anchor's invalidDateError — {format} interpolates — and unavailableDateError) |
captionLayout | "label" | "dropdown" | "dropdown-months" | "dropdown-years" | "label" | Month/year menus; reverseYears flips the year order |
markers | matcher or { match, intent, label }[] | — | Intent dots + accessible-name suffix; showLegend builds the legend from labelled entries |
renderDayExtra | (date, modifiers) => ReactNode | — | Second line inside day cells |
selectionVariant | "fill" | "outline" | "line" | "fill" | Selection ink style |
showTodayButton | boolean | false | Footer Today button (auto-hides out of bounds) |
weekStartsOn | 0–6 | locale | First day of week |
excludeDisabled | boolean | false | Range resets when it would include a disabled day |
min / max | number | — | Range: nights between endpoints · multiple: pick counts |
calendarProps | Partial<CalendarProps> | — | Escape hatch, spread last onto the Calendar (wins) |
Presets
| Prop | Type | Default | Description |
|---|---|---|---|
presets | PresetItem[] | — | { label, getValue } shortcuts; active state tracks the selection in every mode |
presetsPosition | "left" | "top" | "left" | Sidebar or chip strip |
Date & time panel
| Prop | Type | Default | Description |
|---|---|---|---|
showDateTime | boolean | false | Render the calendar's Apply panel (start/end date + time) |
showTime | boolean | true | Include the time fields |
showTimezone | boolean | false | Timezone select in the panel |
timezone / onTimezoneChange | string / (zone) => void | — | Controlled IANA zone |
dateTimeLayout | "vertical" | "horizontal" | "vertical" | Panel beside or below the grid |
onApply | (data: CalendarApplyData) => void | — | Apply payload (start/startTime/end/endTime/dates/timezone); Apply also closes the popover |
footer | ReactNode | — | Custom footer slot (when showDateTime is off); its presence also suppresses close-on-select |
Values are plain local Dates — the timezone select reports a choice in the Apply payload and never converts the dates (the Mantine/Astryx stance; timezone math belongs to the consumer).
Popover
| Prop | Type | Default | Description |
|---|---|---|---|
rounded | "none" | "sm" | "md" | "lg" | "xl" | "full" | "lg" | Popup + calendar + trigger cascade; full caps the panel at 24px per the panel rule |
align / side | positioning | "center" ("start" under the input anchor) / "bottom" | Popover placement; the input anchor's popup hangs from the whole field |
open / defaultOpen / onOpenChange | controlled open | — | reason carries Base UI's reasons plus "select", "preset-press", "apply-press" |
dir | "ltr" | "rtl" | — | Stamped and fed to Base UI only when set; unset leaves the document direction alone |
className | string | — | Wrapper classes; other root props split aria-* → trigger, rest → wrapper |
Keyboard
| Key | Where | Action |
|---|---|---|
Enter / Space | trigger | Open; focus moves to the selected day (else today) |
Escape | popup | Close, focus returns to the trigger |
Escape | closed trigger | Clear (only with clearable and a value; otherwise not consumed) |
Enter | input (input anchor) | Read the typed date; text that can't be read stays, with an error |
Tab | input | Also reads the typed date, then moves to the clear button (if shown) and the calendar button. Focusing the input never opens the popup |
Escape | input | Revert an edit; on an unedited clearable field, clear. Not consumed otherwise |
Enter / Space | calendar button | Open; on close focus returns to the button |
| Arrows | day grid | Move by day/week |
Shift+←/→ · Shift+↑/↓ | day grid | Previous/next month · year (react-day-picker) |
PageUp / PageDown (+Shift) | day grid | Previous/next month (year) keeping the day |
Home / End | day grid | Start/end of week |
Enter / Space | day | Select; single mode then closes and returns focus |
Tab | popup | Through nav, dropdowns, presets, panel fields — the popover is not a focus trap |
Accessibility
- Pattern. A
buttontrigger (aria-haspopup="dialog") opening a named non-modal dialog (role="dialog"from Base UI Popover,aria-labelper mode — "Choose date" / "Choose date range" / "Choose dates", localized). The APG date-picker-dialog pattern is followed for focus and keyboard, with React Aria's non-modal popover stance instead ofaria-modal— no modality is claimed that isn't enforced. - Input anchor pattern.
anchor="input"renders a plain text input (nocomboboxrole — it owns no listbox) and a separate calendarbuttonthat is its own tab stop and the only popup entry, the APG date-picker-dialog shape. Unlike Combobox's trigger, which leaves the tab order because ArrowDown opens its list, this button has to stay reachable: a text input has no other keyboard path to the calendar. On close, focus returns to the button. - Initial focus lands on the selected day, else today (react-day-picker
autoFocus), not the first nav button. - Names. With a
label, the button trigger's accessible name is label + current value ("Departure Jun 24, 2026"), so the value is announced from the collapsed state;data-emptymarks the placeholder state. The input anchor is named by its<label>and announces its own value; its calendar button reads "Choose date, Jun 24, 2026" once a value is set. Helper/error text ridesaria-describedby; the error swap happens inside one polite region. - One live region. A wrapper-level
role="status"announces the committed selection (localized,formatRange-deduplicated). The calendar's own caption region announces month changes. No nested regions. - On the button anchor,
aria-invalidandaria-requiredare deliberately absent — neither is a supported attribute onrole="button"(axe critical); the error state rides the described-by message, the intent boundary anddata-invalid, and required rides the label name. The input anchor stamps both natively: they are legal on a real input. - Touch targets: trigger ≥44px on phones (Button floor); the input anchor's field floors at 44px and its text at 16px, so iOS does not zoom on focus (the Button type ladder only applies to the button anchor); day cells and panel controls follow the calendar's TES-23 floors.
- AT matrix: VoiceOver verified locally. NVDA and JAWS: UNTESTED on this machine — status stays
betauntil a pass is on record. - Known limitation.
readOnly/isLoadingtriggers exposearia-disabled(a read-only button has no native state); the value stays in the name.
Agent notes
Stable hooks: data-slot="date-picker" (wrapper, data-mode, data-anchor="button" | "input"), date-picker-input-shell and date-picker-input (input anchor — its calendar button keeps date-picker-trigger), date-picker-trigger (data-empty/data-invalid/data-readonly/data-loading), date-picker-value, date-picker-clear, date-picker-popup, date-picker-presets, date-picker-label, date-picker-supporting, date-picker-announcer, date-picker-footer, plus every calendar-* slot inside the popup. The trigger also carries Base UI's data-popup-open while open.
Migration
| Old | New | Notes |
|---|---|---|
disabled={matchers} | disabledDates={matchers} | Bridged for one minor with a dev warning; disabled is now the boolean trigger state |
DateTimeApplyData | CalendarApplyData | Same shape; the alias remains for one minor. The payload gains dates[] in multiple mode |
rounded="full" popup 36px | 24px (rounded-3xl) | The panel-radius rule; xl (also 24px on the popup, 12px on the trigger) is new |
| Popup stayed open after single select | Closes + returns focus | shouldCloseOnSelect={false} restores the old behavior |
dir defaulted "ltr" | unset by default | Set dir explicitly to stamp it |
| Trigger label always en-US | localized via locale | — |
| — | anchor="input" | New and additive; the button stays the default |
PresetItem / DateRange
import { createSinglePresets, createRangePresets } from "@tessinaui/ui";
import type { DateRange, PresetItem } from "@tessinaui/ui";
interface PresetItem {
label: string;
getValue: () => Date | DateRange;
}