Skip to content

DatePicker

One day, chosen from a calendar. The month name and the year are each a button that opens a grid of its own, so any month is two clicks away and any year is three.

tsx
import { DatePicker } from 'neba';

<DatePicker label="Ships on" placeholder="Pick a day" clearable />;

Props

PropTypeDefaultDescription
variantshared'solid' | 'outline' | 'text''outline'Weight of the surface. The same shell as a TextField and a Select
sizeshared'xs' | 'sm' | 'md' | 'lg' | 'xl''md'Height and type scale. A day cell is on the same ladder — 32px at md
colorshared'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info''primary'Semantic colour role. Arbitrary colour values are not accepted
densityshared'default' | 'compact''default'Padding only — never the height, never the type scale
elevationshared0 | 1 | 2 | 30Drop shadow depth of the trigger. The popup carries its own
valueDate | nullThe chosen day. Use with onValueChange for a controlled picker
defaultValueDate | nullThe initial value, for an uncontrolled picker
onValueChange(value: Date | null) => voidCalled when the chosen day changes
defaultMonthDateWhich month the calendar opens on when there is no value. Defaults to this one
minDateDate | nullThe earliest day that may be chosen. Day-granular — the time of day is ignored
maxDateDate | nullThe other end of the same span
shouldDisableDate(date: Date) => booleanBlocks individual days inside the range — weekends, holidays, a room already booked. The cell stays in the grid, unavailable
weekStartsOnshared0 | 1 | 2 | 3 | 4 | 5 | 6Which day the week starts on, Sunday being 0. Defaults to whatever the locale says
showTodayButtonbooleantrueOffers the shortcut to today in the footer
openbooleanWhether the popup is open. Use with onOpenChange for a controlled one
defaultOpenbooleanfalseWhether it starts open
onOpenChange(open: boolean) => voidCalled when the popup opens or closes
localestringBCP 47 tag deciding the month and weekday names, the order of the header's two buttons, and how the trigger writes the value. Defaults to the browser's
formatIntl.DateTimeFormatOptionsHow the trigger writes the value. Passed straight to Intl
placeholderReactNodeShown in the trigger while nothing is chosen
clearablebooleanfalseOffers the × that empties the control
closeOnSelectbooleantrueCloses the popup as soon as a day is chosen. True by default, because only one thing was asked
labelsPartial<PickerLabels>The strings a screen reader hears. One object rather than eighteen props, because they are a set — the date names are not among them, those come from Intl
namestringIdentifies the field when a form is submitted, as YYYY-MM-DD — local, not UTC
fullWidthbooleanfalseStretches to the width of the container
labelReactNodeThe label, wired to the control by Base UI's Field
descriptionReactNodeHelper text
errorReactNodeError message. Its presence turns the control invalid and re-points the colour family at danger
invalidboolean!!errorForces the invalid state without a message, for when a form library owns validity
readOnlybooleanfalseShown but not changeable. Keeps its colour and edge, drains the saturation
disabledbooleanfalseUnavailable. Drops the colour family for neutral grey

The value is a Date

Not a string, not a timestamp, and not a wrapper object. Date is what the platform already has, it is what Intl formats, and it is what every other library a caller might be using can be converted from in one line.

There is no date library underneath, on purpose. Neba's one runtime dependency is Base UI, and a component library that quietly adds date-fns — or worse, picks a side in the dayjs/luxon/Temporal argument on its consumer's behalf — has made a decision that was not its to make. The arithmetic here is a dozen lines and the naming is Intl, which knows more about month names in more languages than any bundled table ever will.

Everything is compared on the local calendar day, never on the underlying timestamp. A calendar day is a thing a person is looking at on a wall, not an instant on a line — so a picker in Seoul and a picker in São Paulo both light up the cell that says 27. For the same reason the hidden input a form submits writes 2026-07-27 in local time: toISOString() on that same value would report 2026-07-26, which is the single most expensive bug a date picker can ship.

Three views, not one

A picker that only steps a month at a time puts a birthday thirty years back a hundred and eighty clicks away. So the header carries two buttons rather than a label:

  • the month name opens a grid of twelve months,
  • the year opens a grid of twelve years, with the steppers moving a page at a time.

Choosing a year hands over to the month grid rather than all the way back to days: having just said which year, the next question is which month. The two buttons are printed in the order the locale writes them — July 2026 in English, 2026년 7월 in Korean — and the three views are the same width and the same height, so switching between them never resizes the popup under the pointer that opened it.

Examples

Variants

The same three weights as TextField, on the same shell. A form where the date field is a different height, radius or colour from the fields around it is a form that looks assembled rather than designed.

Sizes

A day cell is on the control ladder: 32px at md, which is a md Button and a md TextField. A calendar dropped beside a form is on the form's grid.

Bounds

minDate and maxDate are compared at day granularity, so a maximum of the 27th at 09:00 still leaves the 27th pickable. shouldDisableDate blocks the days that are inside the range but still not available.

A blocked cell keeps its place in the grid and reports itself with aria-disabled rather than the disabled attribute — a disabled button leaves the tab order and the grid's arrow-key path with it, so a reader would fall into a hole at every blocked day.

States

Why the trigger cannot be typed into

The trigger is a button, exactly as a Select's is, and not a text input.

Parsing a date out of free text is locale-dependent in a way that cannot be done honestly without a date library. A field that understands 27/7/26 in one browser and not in the next — or that reads it as the 7th of December for half its readers — is worse than one that never claimed to. The calendar is where the answer comes from, and the three views are what make that fast enough not to miss the keyboard.

Keyboard

KeyWhat it does
Space / EnterOpens the calendar; the grid takes the focus on the chosen day
Moves by a day or a week, stepping the month at the edges
Home / EndTo the start or the end of the week
PageUp / PageDownBy a month — with Shift, by a year
EscapeCloses without choosing

The grid has a single roving tab stop, so Tab leaves it rather than walking forty-two cells.

Accessibility

  • The grid is a role="grid" of role="gridcell" buttons, each named with the full date rather than the bare number — Monday, July 27, 2026, not 27.
  • The chosen day carries aria-selected; today carries aria-current="date" and a dot under the number, because a colour on its own says it only to some readers.
  • label names the trigger, and description and error are wired to it with aria-describedby.
  • The popup is portalled to the end of <body>; its positioner carries neba-portal as a hook for an app that has scoped its CSS reset to a subtree.

Released under the MIT License