flyingsalmon

Date Picker

A form field for one calendar day or one range, entered by typing into date segments or by picking from a calendar in a floating panel. It owns the label, the error, the panel, and the hidden native inputs; the grid inside the panel is Calendar.

Dates cross the boundary as ISO YYYY-MM-DD strings. The same shape Calendar uses, so a value moves between the two without a conversion and posts straight into a hidden native input.

One day

The default mode. value is a string or null, onChange fires with the ISO day, and name posts it. The segments carry their own mm, dd, and yyyy placeholders in the locale’s order, so there is no placeholder prop and no format prop. min is set to today here, so an earlier day is refused in the grid and rejected when typed.

Light
9252026

Leaving 2026-09-25.

Dark
9252026

Leaving 2026-09-25.

A range

mode={DatePickerMode.Range} takes a { start, end } pair and posts it under startName and endName. Both groups of segments share one box, one label, and one error message, with a static dash between them. The panel shows two months, always.

Picking the end day commits, closes the panel, and returns focus to whatever opened it. Escape or a click outside mid-range closes and leaves the committed range in place in one press, through Calendar’s reset commit behaviour: the app never sees a half-selection.

Light
1012026
1082026

2026-10-01 to 2026-10-08.

Dark
1012026
1082026

2026-10-01 to 2026-10-08.

States

error is the app’s message and wins over the built-in ones. loading replaces the calendar button with a spinner in the same footprint, red when the field shows an error, so opening is blocked and the end slot has nothing to hover or tab to. It also makes the segments read-only, hides the clear button, and sets aria-busy. disabled takes the whole field out of the tab order. The last field below holds a Saturday against an isDateDisabled that refuses weekends, so it renders rejected: the invalid ring, the built-in unavailableMessage, and nothing posted. A reversed range shows rangeOrderMessage instead. Both are props, so they translate.

Light
9252026

That flight is sold out

mmddyyyy
9252026
9262026

That date isn't available

Dark
9252026

That flight is sold out

mmddyyyy
9252026
9262026

That date isn't available

Props

One export, DatePicker. mode is DatePickerMode.Single by default or DatePickerMode.Range, and the value, the handler, and the name props follow it: name in single mode, startName and endName in range mode. The field family props are label, labelPlacement, error, disabled, required, readOnly, id, size (DatePickerSize.Default or Small, Select’s trigger geometry), and loading. min, max, isDateDisabled, and locale reach the segments and the grid alike. side and align position the panel and default to bottom and center per ADR 0005.

A partly typed entry is silent. No ring, no message, no onChange, and the hidden inputs stay empty until every segment is filled — the year included, so typing 2, 0, 2, 6 reports once rather than four times. A complete entry the constraints refuse is a rejected entry: the box takes the invalid ring and aria-invalid, the built-in message renders, and onChange still does not fire. Nothing is posted either way, so a form cannot submit a value the field refused.

Structure

One bordered box holds the segments and the end slot. The box draws the boundary ring on focus-within, per ADR 0003: the segments are what take focus, and they own no border to replace. The end slot holds the clear button, then the calendar icon button, both ghost buttons at Button’s field-icon size, so each sits 4px inside the box’s top, bottom, and right edges with its corners rounded to match. Neither draws a focus ring: tabbing to one paints its hover fill, and the box’s own ring stays on. Clear shows once a value is set, on a field that is not required, readOnly, loading, or disabled — every state where emptying the value is not the user’s to do. Behind the box sit visually hidden native inputs carrying the ISO values, the names, and required, so the browser’s own constraint validation blocks the submit and puts its bubble at the field. A readOnly field is exempt from that check, as a read-only native control is: the user has no way to satisfy it.

The panel is the Radix popover primitive, portalled to document.body with sideOffset 8, flipping then shifting at a viewport edge. Calendar is its only content: one month in single mode, two in range mode, no footer and no actions. Focus lands on the selected day, or today when the field is empty.

Keyboard

Tab enters the first segment, then each segment in turn, then the clear button when it is shown, then the calendar button. In a segment, digits type, ArrowUp and ArrowDown step, ArrowLeft and ArrowRight move between segments, and Backspace clears. Alt+ArrowDown opens the panel from anywhere in the box; Enter and Space open it from the calendar button. Inside the panel the map is React Aria’s, unmodified. Clearing keeps focus on the clear button until it hides, then moves to the calendar button.

In range mode the two groups are two date fields, not one. React Aria ships no DateRangeField, so ArrowRight at the end of the start group stops there and Tab is the way across. Screen readers hear the group, then “Start date” and “End date”, then each segment.

Motion

The panel runs the ADR 0005 anchored pair, 250ms bouncing in from scale-96 at the popper origin and 150ms settling out, as a CSS keyframe Radix waits on. The grid’s motion is Calendar’s and date-picker adds none. The focused segment’s colour and the box border move at --motion-fast. Messages ride FieldErrorMessage’s settle. The clear button and the spinner swap instantly.

Accessibility

Measured against this theme’s palette with an OKLCH-to-sRGB contrast check, light then dark. A filled segment sits at 19.79:1 and 18.96:1 on --background; a placeholder segment and the dash at 4.73:1 and 7.63:1; the invalid ring at 4.76:1 and 6.84:1. The icon buttons sit at 4.73:1 and 7.63:1 at rest and 14.22:1 and 9.95:1 on the fill they share between hover and focus.

The focused segment paints --indicator. It sits at 4.58:1 and 6.34:1, clearing AA in both modes. --primary is the other indigo a field could reach for, and it is indigo-400 in both modes here: 3.12:1 on white, which fails AA for text. --indicator steps to indigo-500 in light and is the step this system already uses when indigo has to carry meaning.