Skip to content

TimePicker

A time of day, chosen from columns. The bounds are checked against the span a row stands for, which is what keeps half past nine reachable when the minimum is 09:30.

tsx
import { TimePicker } from 'neba';

<TimePicker label="Starts at" placeholder="Pick a time" minuteStep={15} clearable />;

Props

PropTypeDefaultDescription
variantshared'solid' | 'outline' | 'text''outline'Weight of the surface: filled, hairline, or none
sizeshared'xs' | 'sm' | 'md' | 'lg' | 'xl''md'Height and type scale. A row of the clock is the same height
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. 0 means no shadow at all
valueDate | nullThe chosen time. A Date, so it carries a day as well — see referenceDate
defaultValueDate | nullThe initial value, for an uncontrolled picker
onValueChange(value: Date | null) => voidCalled when the chosen time changes
referenceDateDatetodayThe day a chosen time is written onto while there is no value yet
minTimeDate | nullThe earliest time of day that may be chosen — only the clock is read. Compared against the span a row stands for, so 09:30 keeps the hour 9 and greys out the minutes before it
maxTimeDate | nullThe other end of the same span
hour12booleanA 12-hour dial with an AM/PM column. Defaults to whatever the locale does
showSecondsbooleanfalseAdds the seconds column
hourStepnumber1How far apart the rows of the hour column are
minuteStepnumber1The same, for minutes
secondStepnumber1The same, for seconds
shouldDisableTime(value: Date, unit: 'hour' | 'minute' | 'second' | 'meridiem') => booleanBlocks individual rows. Called once per row per column with the instant that row would produce and the column it is in
showNowButtonbooleantrueOffers the shortcut to the current time 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
closeOnSelectbooleanfalseCloses the popup as soon as a column is touched. False by default, unlike DatePicker: a time is two answers, and closing after the first would make choosing 9:30 a matter of opening the popup twice
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 HH:MM (HH:MM:SS with seconds shown)
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

Columns, not a dial

Columns are the shape that answers what a time picker is actually asked. "Half past nine" is two glances, and "any time at all, on the hour" is a column you never touch. A clock face is prettier and needs a transform to read, which this library does not have.

The chosen row in each column is scrolled into view once, when the popup opens. That is the only imperative work in the component and it is not optional: a column of sixty minutes that opens at 00 while the value is 45 has hidden its own answer.

hour12 defaults to whatever the locale does, and the 12-hour column runs 12, 1, 2 … 11 — the order a dial is read in, not 0…11.

The value is a Date

Not a string and not a number of minutes, because everything else in this library that carries a moment is a Date, and because a bare time has nowhere to record that it crossed a daylight-saving boundary. referenceDate is the day a chosen time is written onto while the picker is still empty; it defaults to today and is held still for as long as the picker is mounted, so a popup left open across midnight does not quietly move the value onto a new day.

Examples

Steps, seconds and a 24-hour dial

Bounds

This is the detail that separates a working time picker from a frustrating one. minTime and maxTime are compared against the span a row covers, not against one instant inside it: with a minimum of 09:30 the hour 9 covers 09:00:00–09:59:59, which overlaps what is allowed, so it stays available and the minute column is where 00 through 25 grey out.

Comparing the whole candidate instead — which is the obvious implementation — hides the 9 entirely and makes half past nine unreachable.

shouldDisableTime is handed the instant a row would produce and the column it belongs to, so a rule may be as coarse as "no lunch hour" or as fine as one minute.

Why the popup stays open

closeOnSelect is false here and true on DatePicker, which is not an inconsistency. A time is two answers — the hour and the minute — and closing after the first would make choosing 9:30 a matter of opening the popup twice. The footer therefore carries a Done button, which is the thing to press that means "that is the one".

Accessibility

  • Each column is a role="listbox" of role="option" rows, named Hour, Minute, Second and AM/PM. Those four names come from labels and have English defaults.
  • The chosen row in each column carries aria-selected; a blocked one carries aria-disabled rather than the disabled attribute, so it stays reachable and announces why it cannot be taken.
  • A live region beside the columns reads out the whole time whenever it changes. Three unlabelled lists of numbers say nothing on their own to someone reading the screen rather than looking at it.

Released under the MIT License