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.
pnpm add @tessinaui/uiWhen to use
- A date whose calendar context matters. Day of week, distance from today or availability around it, picked from a compact trigger.
- Dates with limits or blocked days. Bounds,
disabledDatesandunavailableDateskeep people from choosing a day that cannot work. - Typed entry in forms with
anchor="input": an invoice or start date people already know and would rather type. The calendar stays one Tab away. Single dates only; ranges keep the button. - A range, several dates, or a date and time from one trigger:
mode="range"with presets,mode="multiple", or the date-time Apply panel. - A label that carries the format. Put format expectations in
supportingTextor the label, never placeholder-only: a placeholder disappears the moment a value exists. The input anchor puts the locale's format there by default.
When not to use
- For a calendar that stays on the page. Use Calendar: the same grid inline, no popover. Everything DatePicker forwards (markers, booked days, selection variants, the date-time panel) also works there directly.
- For a memorable, known date such as a date of birth. Typing a date you
already know beats navigating to it, so use a plain
Field. If you keep a picker for it, use the
captionLayout="dropdown"recipe (see Date of birth) rather than making someone page back through hundreds of months. - For a time alone. Use TimePicker; the built-in date-time panel covers date and time together.
- To choose among a handful of known dates, such as delivery slots. Use Select or Combobox rather than any-date entry.
- When people only have a relative time like "now" or "tonight". Offer it as an option in a Select, or as a preset.
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}
/>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.
A multiple selection's summary follows the locale's plural rules: in Arabic, two dates, three to ten, and eleven or more each take their own form.
Sizes
Calendar cell density across sm, md, and lg — trigger, chips, panel fields and popup all follow. Trigger heights land on 32/40/48px with a mouse; on a touch screen the 44px touch floor raises sm and md to 44px while lg keeps its 48px, so the smaller two deliberately look alike there — 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"] | "filled" | "outline" | Button anchor: outline reads as a field well, filled as the tinted well with no outline until focus or an intent; 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> | English, Arabic under dir="rtl" | Override any UI string (calendar strings + dialogLabel*, clearLabel, datesSelected, the optionalLabel marker, and the input anchor's invalidDateError — {format} interpolates — and unavailableDateError). datesSelected takes plural variants for the locale, datesSelected_<category> (datesSelected_few), picked by Intl.PluralRules; overriding datesSelected drops the Arabic table's variants |
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 — timezone math belongs to the consumer, which alone knows what the date means.
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" | inherited | Stamped only when set. Unset, the picker follows the page, keyboard included |
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 a 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: on any touch screen, tablets included, the trigger is ≥44px (Button floor) and the clear button carries a 44px tap area on both anchors; 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 floors. On a 320px screen the popup keeps 1px from each edge so its 44px grid fits whole.
- AT matrix: VoiceOver + Safari passed 2026-09-14. The trigger reads "Jun 24, 2026 dialog pop up button". Space opens "Choose date web dialog", and ArrowRight lands on "Thursday, June 25th, 2026 button" inside "June 2026 table 7 columns, 5 rows". Enter closes it and the status region reads "Jun 25, 2026". NVDA and JAWS: UNTESTED on this machine.
- 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;
}