Date picker

A field that opens a month grid in a native popover, and the month grid on its own — single dates or a range, by keyboard or by pointer.

Try it

Typed as month, day, year.
Type digits only, as month, day, year. Separators are added for you.

The field already reads 04/26/2023 — type over it, or open the panel with the trigger.

Choosing a picker

When the reader knows the date — a date of birth, the day on a letter — typing beats picking, and the masked field is why the field comes first: eight digits and the separators arrive on their own. When the date is near and open — an appointment next week — the grid is faster, because the choice is made by looking. Both are one control, so the reader decides, not the form.

A range is for a stay, a window, a report period: one field, two dates, one band on the grid. min, max and isDateUnavailable say what can be booked before a date is chosen, which is kinder than refusing one afterwards, and the message that follows a refusal says what to enter, not what was wrong. ACalendar on its own belongs where a field would be odd: a page that is the calendar.

Not the platform’s <input type="date">: it draws a different control in every browser, has no range, and cannot grey a closed day. Everything it does well — the numeric keyboard on a phone, the spoken parts — this field does through inputMode and the description of its mask.

Anatomy and sizes

Three parts. The field is an Input with a mask, and the trigger beside it is a button that opens the panel; both share Button’s three heights, so a picker sits level with the controls around it. The drawing’s md trigger is this code’s lg — the same one-step remap the field already made once between its own md and Button’s. The panel holds a 280px grid of 40px cells with a 32px pill for a day, a month heading, and Previous and Next; the grid never shrinks below its width, because the 40px cell is what the focus ring and the touch target were measured in.

Type digits only, as month, day, year. Separators are added for you.
Type digits only, as month, day, year. Separators are added for you.
Type digits only, as month, day, year. Separators are added for you.

Typing a date

The field is one masked input. Type digits only: the separator appears as each part is complete. A first digit that cannot start its part gains a leading zero (a month of 4 becomes 04), and so does a single digit followed by a separator (1/ becomes 01/). A digit that would make a part impossible (a month of 13) is not taken. Pasting, autofill and deleting in the middle all go through the same rebuild from the digits, and a deletion is never refused. ISO (2023-04-26) is read when it arrives whole — pasted or autofilled — in any locale.

Type digits only, as month, day, year. Separators are added for you.
Type digits only, as day, month, year. Separators are added for you.
Type digits only, as day, month, year. Separators are added for you.

The field checks a date when its last digit is in, on blur and on Enter — never half way, since half a date is unfinished rather than wrong. Each check ends in exactly one call: onSelect with the date (or null for an emptied field), or onInvalid with a reason. Refused text stays as typed and the previous value stays intact. The picker never writes the message: the caller does, into the Field’s error, because only the caller knows what the field is called and why a day is unavailable.

A weekday from April 3 to April 24, 2023.
Type digits only, as month, day, year. Separators are added for you.
Reasons, and the messages this page writes for them
ReasonMessage
incompleteEnter the month, day and year
not-a-dateEnter a date that exists
before-minChoose a date on or after April 3, 2023
after-maxChoose a date on or before April 24, 2023
unavailableChoose a weekday

In range mode the field takes both dates: sixteen digits, joined after the first eight by an en dash with a space on each side (04/05/2023 – 04/10/2023), and a pair typed in reverse is put in order, as the calendar would.

Bounds and exclusions

min and max disable the days outside them; isDateUnavailable disables individual days inside them — closed days, booked days. Both leave the day focusable with aria-disabled rather than removing it, so the grid’s geometry and its tab order never change shape around a clinic’s calendar.

April 2023

SundayMondayTuesdayWednesdayThursdayFridaySaturday

Range

In range mode the first click paints a pending start and the second completes the range; onSelect fires once, with both ends. The band runs the whole 40px cell rather than the 32px pill, so consecutive days read as one period rather than loose squares, and its ends are the cell’s own rounded corners.

The band survives a month boundary

Days from the adjacent month are drawn, greyed with text/inert and inert: not a tab stop, not clickable, their number hidden from a screen reader. That is what makes their low contrast defensible — a control has to clear WCAG for text, decoration does not. But a spilled day still takes the range band when a selection covers it. Unpainted, the band would break exactly at the boundary a range is most likely to cross.

March 2023

SundayMondayTuesdayWednesdayThursdayFridaySaturday

With March open, the cells spilled in at the top are 26–28 February — inside a range that started on the 20th, so all three carry the accent band.

Type digits only, as month, day, year. Separators are added for you. Then the end date the same way.

The Calendar on its own

The field, the popover and the typing are all DatePicker. Underneath is a plain Calendar — a grid with no opinion about how it got on screen, usable anywhere a picker’s trigger and text field would be wrong, such as inline in a page.

April 2023

SundayMondayTuesdayWednesdayThursdayFridaySaturday

The panel

It is a native popover="manual", anchored to the field with CSS anchor positioning — the top layer, so it escapes overflow: hidden without a portal, and CSS places and flips it without positioning JavaScript. Not auto, the way DropdownMenu is: in range mode the first Escape has to cancel a pending start rather than close anything, and that has to run before the platform’s own close request would take the panel out from under it. Dismissal is the component’s own instead — Escape, a pointer press outside, and focus leaving the field and panel together all close it, and Tab wraps between the header’s Previous/Next and the grid rather than escaping to the rest of the page.

The panel is surface/overlay, the system’s elevation/md shadow, and in dark a border/default hairline the shadow alone no longer supplies — see the gutter for what that leaves to read against.

Dismissal — Escape with its range layering, the outside press, focus leaving, focus returning to the trigger, the Tab wrap — is the component’s own and the suite tests all of it through a popover stub, since jsdom has none of the API. The top layer, the anchor placement and the flip are the browser’s, and have not yet been checked by hand.

Accessibility

The trigger names itself Choose date until there is a value, then Change date, April 26, 2023 — always the long, spoken form, even while the field itself is showing an uncommitted draft. Inside a Field the visible label already names the text input; the trigger carries its own name regardless, since confirming the value is its job even for a screen-reader user who never reads the field.

The field describes its mask in words — “Type digits only, as month, day, year. Separators are added for you.”, with the three parts in the locale’s order (the pt-PT and de-DE fields above say “day, month, year”) — through aria-describedby, after the Field’s own description and error, because a screen reader reads MM/DD/YYYY letter by letter. A digit the mask refuses makes no sound: announcing each one would talk over the reader’s own echo of the key, so the rule is stated before anyone meets it. The format still to type is drawn behind the text in text/placeholder, hidden from the accessibility tree.

The grid is a single tab stop, roving with the arrow keys, Home, End and Page Up/Down (Shift for a year). A day excluded by isDateUnavailable stays in that sequence with aria-disabled rather than disabled — reachable, so a screen reader hears why a day is excluded instead of finding a hole in the month. Days outside min and max are drawn the same way, but the arrow keys clamp to the bounds, so a move never lands past them.

Props

Calendar props
PropTypeDefault
labelstringrequired
mode'single' | 'range''single'
monthISODate— (uncontrolled)
defaultMonthISODate
onMonthChange(next: ISODate) => void
weekStartsOn0 | 1 | 2 | 3 | 4 | 5 | 60
localestring'en-US'
valueISODate | DateRange | null
onSelect(next: ISODate | DateRange | null) => void— (range mode: fires only with a complete range)
minISODate
maxISODate
isDateUnavailable(date: ISODate) => boolean

weekStartsOn defaults to Sunday and is never derived from locale — the platform’s own locale week data disagrees with pt-PT, so deriving it would be silently wrong for most of Europe.

DateRange is { start: ISODate; end: ISODate }end is never null. In range mode the first click only paints a pending start; onSelect fires once, with both ends, on the second.

The DatePicker takes every prop above, plus:

DatePicker props
PropTypeDefault
size'sm' | 'md' | 'lg''md'
invalidboolean— (or the Field it sits in)
disabledbooleanfalse
readOnlybooleanfalse
idstring— (or the Field it sits in)
requiredboolean— (or the Field it sits in)
aria-describedbystring— (or the Field it sits in)
namestring— (submits ISO from a hidden input: a date, or start/end for a range)
onInvalid(raw: string, reason: DatePickerInvalidReason) => void— (reason: incomplete | not-a-date | before-min | after-max | unavailable)