Date picker
StableA field that shows the chosen date and opens a month grid in a popover. Values are ISO date strings.
It follows the APG date picker dialog: the popover opens on the chosen date (or today), the arrow and Page keys move through the month, Enter chooses and Esc closes without a change, and either way focus goes back to the field. min, max and isDateUnavailable limit what can be chosen, and name submits the ISO value with a form. CalendarPanel is the popover’s content on its own, for inline use.
Ship date
import { useState } from 'react'import { DatePicker } from '@rchursin/product/calendar'
/** The field shows the date in the visitor's words; the value it hands back is a plain ISO string. */export default function DatePickerBasic() { const [shipDate, setShipDate] = useState<string | null>('2026-10-21')
return ( <div className="flex w-full max-w-72 flex-col gap-3"> <DatePicker label="Ship date" hint="The release goes out that morning." value={shipDate} onChange={setShipDate} /> <p className="font-mono text-[11px] text-fg-3">value: {shipDate === null ? 'null' : `"${shipDate}"`}</p> </div> )}Usage
import { DatePicker, CalendarPanel } from '@rchursin/product/calendar'const [due, setDue] = useState<string | null>('2026-10-21')
<DatePicker label="Due date" value={due} onChange={setDue} />Examples
Panel on its own
CalendarPanel inline, limited to Oct 12 – Nov 20 with weekends unavailable: struck through, still reachable by keyboard, announced as unavailable.
import { useState } from 'react'import { CalendarPanel, parseISODate } from '@rchursin/product/calendar'
/** Weekends can't be chosen: they stay reachable by keyboard and are announced as unavailable. */const isWeekend = (iso: string) => [0, 6].includes(parseISODate(iso)?.getDay() ?? -1)
/** The popover's content on its own, for a sidebar or a booking step. Limited to five weeks of weekdays. */export default function DatePickerPanel() { const [day, setDay] = useState<string | null>('2026-10-21')
return ( <div className="flex flex-col items-center gap-3"> <div className="rc-pop-panel"> <CalendarPanel value={day} onSelect={setDay} onClear={() => setDay(null)} min="2026-10-12" max="2026-11-20" isDateUnavailable={isWeekend} /> </div> <p className="text-xs text-fg-3">Review slots: weekdays, Oct 12 to Nov 20</p> </div> )}In a form
With name, a hidden input submits the ISO value. Press Save task with no date to see the error. Week numbers are on here.
import { useState, type FormEvent } from 'react'import { DatePicker } from '@rchursin/product/calendar'import { Button } from '@rchursin/product/ui'
/** With `name`, the ISO value goes out with the form in a hidden input. The error is shown after a press. */export default function DatePickerForm() { const [due, setDue] = useState<string | null>(null) const [error, setError] = useState<string>() const [sent, setSent] = useState('')
function submit(e: FormEvent<HTMLFormElement>) { e.preventDefault() if (!due) { setError('Choose a due date') setSent('') return } setError(undefined) setSent(`due=${new FormData(e.currentTarget).get('due')}`) }API
DatePicker props
| Prop | Type | Default | Description |
|---|---|---|---|
labelrequired | string | – | Visible label. It is also the start of the field’s accessible name, followed by the date read in full. |
valuerequired | ISODate | null | – | The chosen date as "YYYY-MM-DD", or null for none. |
onChangerequired | (value: ISODate | null) => void | – | Called with the new ISO date when a day is chosen, and with null on Clear. |
min | ISODate | – | Earliest date that can be chosen. Earlier dates are struck through and the keys stop there. |
max | ISODate | – | Latest date that can be chosen. |
isDateUnavailable | (date: ISODate) => boolean | – | Extra dates that can’t be chosen (booked days, weekends). They stay focusable and say “unavailable”. |
placeholder | string | 'Pick a date' | Shown when there is no value. Screen readers hear “no date chosen”. |
hint | string | – | Help text under the label, linked with aria-describedby. |
error | string | – | Error message between the label (and hint) and the field. Sets aria-invalid on the field. |
clearable | boolean | true | Offer Clear at the foot of the panel when there is a value. |
weekNumbers | boolean | – | ISO week numbers down the side of the grid. |
hideLabel | boolean | – | Keep the label for screen readers only. |
name | string | – | Submit the ISO value with a form under this name (a hidden input). |
size | 'md' | 'lg' | 'md' | Field height: 32 or 40 pixels. |
className | string | – | Classes on the wrapper (label, hint, error and field). |
CalendarPanel props
| Prop | Type | Default | Description |
|---|---|---|---|
valuerequired | ISODate | null | – | The selected date. The panel opens on it (or on today) and keeps its own focused month after that. |
onSelectrequired | (date: ISODate) => void | – | A day was chosen by click, Enter, Space or the Today button. |
onClear | () => void | – | When given and there is a value, a Clear button replaces the key hint in the footer. |
min | ISODate | – | Earliest date that can be chosen. Also disables paging back past its month. |
max | ISODate | – | Latest date that can be chosen. Also disables paging forward past its month. |
isDateUnavailable | (date: ISODate) => boolean | – | Extra dates that can’t be chosen. |
weekNumbers | boolean | – | ISO week numbers down the side (the panel widens from 284 to 312 pixels). |
markers | (date: ISODate) => number | – | How many events fall on a date: up to three small squares under the number. |
focusedRef | RefObject<HTMLButtonElement | null> | – | Filled with the focused date cell, so a popover can focus it first. |
className | string | – | Classes on the panel. |
Accessibility
Keyboard
| Key | What it does |
|---|---|
| EnterSpace | On the field: opens the calendar with focus on the chosen date, or on today. |
| Left arrowRight arrow | Previous or next day (swapped in right-to-left layouts). |
| Up arrowDown arrow | The same weekday a week earlier or later. |
| HomeEnd | First or last day of the week, in the dates locale (Sunday or Monday first; see setDateLocale). |
| Page UpPage Down | The same day in the previous or next month, clamped (Mar 31 goes to Feb 28). |
| ShiftPage UpShiftPage Down | The same day a year earlier or later. |
| EnterSpace | On a date: chooses it and closes. Unavailable dates do nothing. |
| Esc | Closes without a change. |
| TabShiftTab | Moves between the year and month buttons, the grid (one stop), Clear and Today, and wraps around at either end: the dialog is modal, so focus stays in it until a choice, Esc or a click outside closes it. |
- The field is a button with aria-haspopup="dialog" and aria-expanded. Its name is the label plus the date in full (“Ship date, Wednesday, October 21, 2026”), or “no date chosen”.
- The popover is a modal dialog (aria-modal, Tab wraps inside it) named “<label>: choose a date”, in the top layer so no scrolling panel clips it. When it closes with focus inside (a choice, Clear, Today, Esc), focus returns to the field.
- The month heading is a polite live region, so paging months is announced.
- Each date’s name carries its state (“Today, Tuesday, October 13, 2026, selected”), because VoiceOver ignores aria-selected on grid cells; today also gets aria-current="date".
- Only one date is in the Tab order (roving tabindex). Unavailable dates stay reachable, are struck through and say “unavailable”, so the reason is never colour alone.
Guidelines
Do
- Use it for dates near today: deadlines, bookings, ship dates.
- Limit choices with min, max and isDateUnavailable instead of rejecting a date after it is picked.
- Keep values as ISO strings end to end, and format them only for display.
Don’t
- Don’t use it for birthdays and other dates people know by heart; use Memorable date.
- Don’t turn the value into a Date to store it: a time-zone change can move it by a day.
- Don’t disable the field to show a date that can’t change; show it as text.
In use
Related
- Date fieldA date typed in segments in the locale’s order, with a calendar button beside it. Paste "2026-10-21" to fill it.
- Range pickerA start and end date for report filters, with analytics presets, typed ends and two linked months.
- Calendar gridOne month as an ARIA grid with the APG date-picker keys. Every picker in the kit is built on it.
- PopoverA panel beside its trigger, on the native popover API: never clipped, closed by Esc or a click outside, and it hands focus back.
- ISO datesDates as "2026-10-12" strings: strict and forgiving parsers, range labels with a spoken form, and the date grid’s arrow-key model.
Source
The component’s own files, exactly as they run on this site.
/** * DatePicker: a field that shows the chosen date and opens a month grid in a popover. * * const [due, setDue] = useState<string | null>('2026-10-16') * <DatePicker label="Due date" value={due} onChange={setDue} min={todayISO()} /> * * API (shared by every picker in this kit): `value` is an ISO date string ("2026-10-16") or null, * and `onChange` hands one back. No Date objects cross the boundary, so values survive JSON, * forms and time-zone changes. `min` / `max` / `isDateUnavailable` limit what can be chosen. * Pass `name` to submit the ISO value with a form. * * Behaviour (APG date picker dialog): the popover opens on the chosen date (or today); arrows, * Page and Home/End keys move; Enter or Space chooses and closes; Esc closes without a change. * Either way focus returns to the field. The month heading is a polite live region, so paging * months is announced. "Today" and "Clear" sit at the foot of the panel. * * CalendarPanel is the popover's content on its own, for inline use (a sidebar, a specimen). */import { useId, useRef, useState, type RefObject } from "react"import { cx } from "../../components/cx"