Composites

Date Picker

A typed date field with a calendar attached, and a range variant with a preset column for reports and list filters. One locale prop moves both halves of the control: the calendar language and the day–month order the input expects.

Single date

The field is a real <input>, not a button that only opens a calendar. Professional data entry — accounting, HR — types faster than it clicks, and a 1975 birthday behind fifty calendar flips is unusable. Type 03/15/2026 and press Enter, or open the calendar; both paths land in the same onValueChange.

value = 2026-05-21

What typing actually commits

Three rules, and they are the reason the field is safe to hand to a fast typist. Try them in the demo above.

  • A half-typed string is never overwritten. Keystrokes only move local text state; the component reformats the input from value only when the value, the pattern or the locale changes from outside.
  • Commit happens on blur and on Enterparse() against the same pattern the placeholder advertises, then startOfDay() so two dates picked on different clocks compare equal.
  • An unparseable or out-of-range entry restores the current value rather than silently emitting null. Nothing is swallowed; the user sees their old date come back. Clearing the field to empty is a deliberate null.
  • Changing locale mid-session re-renders the text through the new pattern. Without that, a string still reading 15/03/2026 in a field now expecting MM/dd/yyyy would parse as a different day on the next blur.

Locale drives the calendar and the input order

dateFormatForLocale() reads locale.formatLong.date({ width: 'short' }) and widens a lone y to yyyy — not to change the output (y already prints 2026) but because the same string is the placeholder, and a hint reading dd/MM/y teaches people to type wrong. The derived pattern feeds format, parse and the placeholder at once, so the three cannot drift apart.

locale={vi}dd/MM/yyyy

locale={enUS}MM/dd/yyyy

locale={ja}yyyy/MM/dd

LocaledateFormatForLocale()21 May 2026 renders as
viVietnamese (package default)dd/MM/yyyy21/05/2026
enUSEnglish (United States)MM/dd/yyyy05/21/2026
jaJapaneseyyyy/MM/dd2026/05/21

VI_DATE_FORMAT (dd/MM/yyyy) is still exported but is no longer the default. Use it only to pin Vietnamese dates regardless of app language — a printed invoice, a tax export. Everywhere else, let the locale decide.

Presets

A preset is { id, label, getValue } where getValue is called at click time, not at module load — so “Today” is still today in a tab left open overnight. The single picker ships with the column hidden (presets={false}); pass an array to turn it on.

value = null

The two shipped constants are Vietnamese. They are exported so you can spread or reorder them, not so an English app can use them as-is:

DEFAULT_DATE_PRESETS
Hôm nayHôm qua7 ngày trước
DEFAULT_DATE_RANGE_PRESETS — applied automatically by DateRangePicker
Hôm nay7 ngày qua30 ngày quaTháng nàyTháng trước

Bounds

minDate and maxDate are enforced twice — the calendar disables the days, and a typed date outside the window is rejected on blur. The demo below is pinned to Q1 2026; try typing 06/01/2026 and tabbing away.

value = 2026-02-10 · window = 2026-01-01 → 2026-03-31

Internally the two bounds become an array of react-day-picker matchers ([{ before }, { after }]), never one object. Putting before and after in the same object makes a DateInterval, which means “the days between” — the exact inverse of the intent. Worth knowing if you ever pass matchers to the raw Calendar primitive yourself.

Date range

The range variant leads with presets, because nine times out of ten the answer is “last 7 days” rather than an arbitrary window — charging two calendar clicks for every report view is a tax on repetition. The trigger is a button showing both formatted dates; there is no typing here.

from = · to =

Two traps in one component. First, presets defaults to DEFAULT_DATE_RANGE_PRESETS, so an English app that only passes locale still shows a Vietnamese preset column — pass your own array or false. Second, onValueChange fires on the first calendar click, delivering { from } with no to. Guard on range?.to before you issue a query, or you will fetch on every half-picked range.

One month, no presets

In a drawer or a narrow filter rail, drop to numberOfMonths={1} and presets={false}. The preset column already collapses to a wrapping chip row below the sm breakpoint, so turn it off only when the shortcuts genuinely do not apply.

The labels contract

DatePickerLabels has three keys and every one of them is screen-reader-only — none of them shows as visible text. That is exactly why they need overriding: an English app looks correct in a screenshot while still announcing Vietnamese, and nothing goes red. locale cannot fix it — a date-fns locale carries date spelling, not interface strings — so an English app passes both.

KeyDefault (vi)English overrideRead out on
clearXóa ngàyClear dateDatePicker — the × button beside the input
clearRangeXóa khoảng ngàyClear date rangeDateRangePicker — the × button on the trigger
openCalendarMở lịchOpen calendarDatePicker — the calendar-icon button
  • labels is Partial<DatePickerLabels>. The component spreads { ...DEFAULT_DATE_PICKER_LABELS, ...labels }, so overriding one key keeps the other two.
  • No key here interpolates a number or a unit, so all three are plain strings — unlike TablePaginationLabels, where such keys are functions.
  • Standalone props beat labels. placeholder is its own prop, not a label key, because it is a per-field string (“Invoice date”) rather than a per-language one. Its DateRangePicker default is the Vietnamese Chọn khoảng ngày, so it needs passing too.
tsx
import { enUS } from 'date-fns/locale'
import { DatePicker, DateRangePicker } from '@comitor/ui'
import type { DatePickerLabels } from '@comitor/ui'

// labels is Partial<DatePickerLabels>: the component always spreads
// { ...DEFAULT_DATE_PICKER_LABELS, ...labels }, so one key can be overridden
// without losing the other two.
const EN_LABELS: Partial<DatePickerLabels> = {
  clear: 'Clear date',
  clearRange: 'Clear date range',
  openCalendar: 'Open calendar',
}

// locale does NOT translate these — a date-fns Locale carries date spelling,
// not interface strings. An English app passes BOTH.
<DatePicker value={date} onValueChange={setDate} locale={enUS} labels={EN_LABELS} />
<DateRangePicker
  value={range}
  onValueChange={setRange}
  locale={enUS}
  labels={EN_LABELS}
  placeholder="Select a date range"   // NOT part of labels — its own prop
/>

Reading the constants from a Server Component

The pickers themselves are 'use client', but the constants live in a separate module that deliberately is not. That matters: every export of a 'use client' file reaches a Server Component as a client-reference proxy that reads as undefined without throwing and without warning. Plain data — default labels, presets, format helpers — is exactly what server pages read most, so it is kept outside that boundary.

tsx
// date-picker-constants has no 'use client' on purpose, so a Server Component
// can read the presets and label defaults directly.
import { enUS } from 'date-fns/locale'
import { DEFAULT_DATE_RANGE_PRESETS, VI_DATE_FORMAT, dateFormatForLocale } from '@comitor/ui'

export default async function Page() {
  const pattern = dateFormatForLocale(enUS) // 'MM/dd/yyyy' — safe on the server
  const presetIds = DEFAULT_DATE_RANGE_PRESETS.map((preset) => preset.id)
  // ...
}

// VI_DATE_FORMAT is still exported, but it is NOT the default any more.
// Reach for it only to pin Vietnamese dates regardless of app language —
// a printed invoice, a tax export file.
VI_DATE_FORMAT // 'dd/MM/yyyy'

DatePicker props

PropTypeDefaultDescription
valueDate | nullThe selected date. Fully controlled — the component stores only the in-progress text of the input, never a date of its own.
onValueChangerequired(value: Date | null) => voidCalled with a startOfDay-normalised date, or null when the field is cleared or emptied.
placeholderstringthe resolved patternDefaults to the format pattern itself (MM/dd/yyyy under enUS), which doubles as a typing hint.
disabledbooleanfalseDisables the input and the calendar trigger, and hides the clear button.
minDateDateEarliest selectable day. Disabled in the calendar and rejected on typed entry.
maxDateDateLatest selectable day, same two enforcement points as minDate.
clearablebooleantrueShows the × button when a value is present and the field is enabled.
presetsDatePreset[] | falsefalseShortcut column to the left of the calendar. Off by default for the single picker.
localeLocale (date-fns)viDrives the calendar language AND the input day–month order via dateFormatForLocale(). Pass it in any non-Vietnamese app.
dateFormatstringfrom localeEscape hatch that pins the typed/displayed pattern. Rarely needed — leaving it out is almost always right.
labelsPartial<DatePickerLabels>DEFAULT_DATE_PICKER_LABELSScreen-reader-only strings for the clear and open-calendar buttons. Vietnamese by default.
classNamestringMerged onto the relative wrapper around the input, not onto the input itself.
idstringForwarded to the <input>, so an external <Label htmlFor> points at the real control.
namestringForwarded to the <input> for uncontrolled form serialisation.
aria-labelstringForwarded to the <input>. Use it when there is no visible label.
aria-describedbystringForwarded to the <input> — point it at your helper or error text.
aria-invalidtrueLiteral true only; the type deliberately excludes false and "false". Pass aria-invalid={errors.date ? true : undefined}.
aria-requiredtrueLiteral true only, same shape as aria-invalid.

DateRangePicker props

PropTypeDefaultDescription
valueDateRange | nullThe selected range. DateRange is { from: Date | undefined; to?: Date } re-exported from @comitor/ui, so you never import react-day-picker yourself. Note from is a required key whose value may be undefined — {} is not a DateRange.
onValueChangerequired(value: DateRange | null) => voidFires on every calendar click, so a half-picked range arrives as { from } with no to. Guard on range?.to before querying.
placeholderstring'Chọn khoảng ngày'Shown on the trigger while empty. Its default is Vietnamese and it is NOT part of labels — pass it explicitly in an English app.
disabledbooleanfalseDisables the trigger and hides the clear button.
minDateDateEarliest selectable day.
maxDateDateLatest selectable day.
clearablebooleantrueShows the × button once a range is present.
presetsDateRangePreset[] | falseDEFAULT_DATE_RANGE_PRESETSShortcut column. ON by default here — and the default labels are Vietnamese. Pass your own array, or false to drop the column.
numberOfMonthsnumber2Months shown side by side. Two is right for report periods; drop to 1 in a narrow drawer.
localeLocale (date-fns)viSame dual role as on DatePicker — calendar language and the order of the two formatted dates on the trigger.
dateFormatstringfrom localePins the display pattern regardless of locale.
labelsPartial<DatePickerLabels>DEFAULT_DATE_PICKER_LABELSOnly clearRange is read here — the trigger is its own calendar opener, so openCalendar is unused.
classNamestringMerged onto the trigger button.
idstringForwarded to the trigger button.
aria-labelstringForwarded to the trigger button.
aria-describedbystringForwarded to the trigger button.
aria-invalidtrueLiteral true only, matching DatePicker. There is no aria-required here.

Types and constants

tsx
import type {
  DatePickerLabels,
  DatePreset,
  DateRange,        // re-exported from react-day-picker so you never depend on it
  DateRangePreset,
} from '@comitor/ui'

interface DatePreset      { id: string; label: string; getValue: () => Date }
interface DateRangePreset { id: string; label: string; getValue: () => DateRange }
interface DatePickerLabels { clear: string; clearRange: string; openCalendar: string }

// Values
DEFAULT_DATE_PICKER_LABELS  // the three Vietnamese sr-only strings
DEFAULT_DATE_PRESETS        // 3 single-date shortcuts (vi labels)
DEFAULT_DATE_RANGE_PRESETS  // 5 range shortcuts (vi labels), applied by default
VI_DATE_FORMAT              // 'dd/MM/yyyy' — no longer the default pattern
dateFormatForLocale(locale) // derive the numeric pattern from a date-fns Locale

Accessibility

  • The single picker is a genuine text input — reachable, editable and dictatable without ever opening the calendar. It carries inputMode="numeric" for the mobile keypad and autoComplete="off" so browser suggestions do not fight the format.
  • Both trailing icons are real Buttons carrying sr-only text from labels; the icons themselves are aria-hidden. They are in the tab order, so a keyboard user can clear the field or open the calendar without a mouse.
  • The icon row is pointer-events-none with pointer-events-auto restored on each button. Without that, the row's padding would cover the right edge of the input and clicking there would fail to place the caret.
  • In DateRangePicker the clear button is a sibling of the popover trigger, not a child of it. A button inside a button is invalid HTML and unreachable by Tab; as a sibling it gets a real focus ring and a 24px hit area.
  • The calendar takes autoFocus when the popover opens, so keyboard users land on the day grid; react-day-picker then owns arrow keys, Home/End and PageUp/PageDown. Escape closes the popover and returns focus to the trigger.
  • The selected day uses the checked-control token pair — --control-on for the fill and --control-check for the numeral — the same pairing as a checked Checkbox. Measured: the day number reaches 10.19:1 in the default palette and 5.77:1 in high contrast; the cell fill reaches 3.28:1 against the calendar surface in high contrast (1.86:1 in the default palette, an accepted exception in the same class as the Switch track).
  • Known gap, deliberately open: the today marker and the middle of a selected range use bg-accent, which is 1.09:1 (light) / 1.17:1 (dark) against the calendar. Pushing the range middle to 3:1 turns a whole week into a solid block, so this is a design question rather than a token swap. Do not rely on the today tint alone to convey anything.
  • aria-invalid and aria-required accept the literal true only — write aria-invalid={errors.date ? true : undefined} rather than passing a boolean expression.
  • Give every picker a name: an id paired with a visible Label htmlFor, or aria-label when there is no visible label. Point aria-describedby at your error text so a rejected entry is explained rather than merely reverted.