Time Picker

A segmented time-of-day field with a column picker attached — the meeting time beside a date, the start of a shift, a payroll cut-off. Whatever clock the reader sees, 02:30 PM or 14:30, your app receives the same 24-hour string.

Import
import { TimePicker, … } from '@comitor/ui' — the main entry, with no optional peer to install. Every part is a client component, and so is the useTimePicker hook.
Origin
Added in 2.1.0, ported from Dice UI (MIT, © 2024 Sadman Sakib) onto the package’s own Popover and tokens. Where it behaves differently from upstream is listed under What the port guarantees.
Changed in 3.0.0
The clock follows the locale — en shows 12-hour AM/PM, vi 24-hour — and every label defaults to English. 2.1.0 was always 24-hour with SA/CH. To keep that, wrap the app in <ComitorLocaleProvider locale="vi">, or pass locale="vi-VN" to this picker. Details in the changelog.
Changed in 3.1.0
min and max are enforced. An app that already passed them, to no effect, now sees greyed-out items and clamped values; a value the app sets itself is still shown as given. Tab in the panel now reaches a Clear placed there. Details in the changelog.

Anatomy

Twelve parts, and none of them appears or disappears on its own — you decide which segments the field has, which columns the panel has, and where Clear goes. The one exception is the period: its segment and its column render nothing on a 24-hour clock, so the same markup serves an English and a Vietnamese reader.

tsx
import {
  TimePicker,
  TimePickerClear,
  TimePickerContent,
  TimePickerHour,
  TimePickerInput,
  TimePickerInputGroup,
  TimePickerLabel,
  TimePickerMinute,
  TimePickerPeriod,
  TimePickerSecond,
  TimePickerSeparator,
  TimePickerTrigger,
} from '@comitor/ui'

<TimePicker value={time} onValueChange={setTime}>  {/* store · clock · hidden form input */}
  <TimePickerLabel>Start time</TimePickerLabel>    {/* <label> that names the group */}
  <TimePickerInputGroup>                           {/* role="group": the bordered field */}
    <TimePickerInput segment="hour" />
    <TimePickerSeparator />                        {/* ":" — aria-hidden */}
    <TimePickerInput segment="minute" />
    <TimePickerSeparator />
    <TimePickerInput segment="second" />           {/* only with showSeconds */}
    <TimePickerInput segment="period" />           {/* renders nothing on a 24-hour clock */}
    <TimePickerTrigger />                          {/* clock button that opens the panel */}
  </TimePickerInputGroup>
  <TimePickerClear />                              {/* anywhere inside TimePicker */}
  <TimePickerContent>                              {/* the panel: one column per unit */}
    <TimePickerHour />
    <TimePickerMinute />
    <TimePickerSecond />                           {/* only with showSeconds */}
    <TimePickerPeriod />                           {/* renders nothing on a 24-hour clock */}
  </TimePickerContent>
</TimePicker>

Beside a date

The field is built to stand next to DatePicker — same h-9 height, border roles, focus ring and error border — and the two together make one appointment. Neither knows about the other, or about time zones: the picker hands you wall-clock time, and composing the instant in the time zone of the person entering it is the app’s job. Clear sits in the label row, where it works without opening the panel. Inside the panel it is just as reachable: since 3.1.0, Tab moves on to it after the columns.

time = "14:30" · startsAt = 2026-10-02T14:30

What the value looks like

Five rules — clear the field above and type to watch each one. The first is the reason the picker is safe to store from; the others are what your validation has to expect.

  • Always 24-hour, zero-padded. HH:mm, or HH:mm:ss with showSeconds. The 02:30 PM on screen is "14:30" in onValueChange, in the hidden form input and in your database. No time at all is an empty string.
  • It is emitted while the user types, blanks included. A segment commits as soon as it is complete, and the picker emits the whole string each time — with -- for a segment that is still empty: "09:--" while only the hour is set, "--:30" after the hour is emptied with Backspace. Treat anything that does not match ^\d{2}:\d{2}$ as unfinished.
  • Leaving a typed segment fills the blanks from the clock. That includes the automatic jump to the next segment: on a 24-hour clock at 15:47, type 9 and the value is 09:47 — the current minute — until a minute is typed; tab away without typing one and that is what stays. Upstream behaviour, kept; with min or max set, the current time is first pulled into the window. The field shows the filled minute, so the user can see it and correct it.
  • On a 12-hour clock an empty field borrows AM/PM from now. Typing 9 at three in the afternoon gives 21:…, with PM showing in the period segment — press A to change it.
  • Impossible digits are never kept. Hours clamp to 1–12 or 0–23 and minutes to 0–59, and a first digit that cannot start a pair — 7 in the minute — commits as 07 at once.

With seconds

showSeconds changes the value format to HH:mm:ss — and nothing else. The second segment and the seconds column are ordinary parts you render yourself; leave them out and seconds are never set, render them without the flag and whatever is typed there is dropped. A payroll cut-off is the typical case: the last second of the day, not the last minute. Four columns need room — the panel is never wider than the field it hangs from, so this one is w-56.

value = "23:59:59"

Between min and max

min and max keep what the user picks or types inside a window — 06:00–22:00 below, the hours a shift may run. Type 5 into the start hour and the value becomes 06:00; open the panel and every hour before 6 AM is greyed out. The rules:

  • An item with no allowed time is disabled, given the larger units already chosen: an hour when all sixty of its minutes are outside the window, a minute when that minute is, AM or PM when the whole half-day is. Disabled items carry aria-disabled, ignore clicks, and ↑ / ↓ skip them.
  • What the user picks or types lands inside. A pick, or a typed segment as it commits — two digits, Tab, Enter, leaving it, an arrow key — that would fall outside becomes the nearest allowed time, keeping what was just chosen if it fits: with max="17:00" at 09:45, choosing the hour 17 gives 17:00 — the hour kept, the minutes pulled back — and typing a later hour gives 17:00 too. Blanks are filled from the current time, pulled into the window, so onValueChange never reports a time the user took outside.
  • min later than max crosses midnight, as on <input type="time">: min="22:00" max="06:00" is a night shift. One bound alone runs to the start or the end of the day. Without showSeconds, a bound with seconds is rounded inward to the minute.
  • Not clamped: a value the app sets through value or defaultValue — shown as given, with its items greyed out — or one that a new min or max leaves outside. Backspace and TimePickerClear empty a segment or the field without filling it back in.

The window is not the whole check, so a small function still runs on every change: it turns HH:mm into minutes, rejects unfinished values, keeps the end after the start and checks the window again for a shift the app loads, then sets invalid and points aria-describedby at the message. Try an end time of 7:00 AM, or empty a segment with Backspace.

start = "08:00" · end = "17:30"

FormData → press Save shift without touching anything

Both pickers carry a name, so each posts its 24-hour value through a hidden input. Press Save shift without touching anything and shiftStart=08:00 and shiftEnd=17:30 arrive. Before 2.1.0 an untouched picker posted an empty string: the hidden input only wrote its value when the value changed, so the first one never reached it. minuteStep={15} here thins the minute column to quarter hours — it does not stop anyone typing 17:37.

Language and region

Two separate axes meet in this component. The labels — segment names, the clock button, the Clear text, the AM/PM words — follow the provider’s language. The clock — 12 or 24-hour — follows the full locale, which is why en-GB matters here: the same English words, a 24-hour clock. The picker’s own locale prop overrides the clock only; the fourth option keeps a Vietnamese interface and forces a 12-hour clock, which shows SA / CH — CLDR’s own Vietnamese abbreviations, what Intl.DateTimeFormat('vi', { hour12: true }) prints.

value
"14:30"
clock
12-hour
segment names
Hour · Minute · AM/PM
period text
AM / PM
clock button
Open time picker
  • Set the language once, at the root. ComitorLocaleProvider in the root layout, with the user’s own language — installation covers it in Set the language. Without a provider the picker is English with a 12-hour clock.
  • Precedence, per string: the labels prop, then the nearest provider’s timePicker namespace, then English. DEFAULT_TIME_PICKER_LABELS is the English table since 3.0.0 — the component no longer reads it, it is there for you to spread. The Vietnamese table is comitorMessagesVi.timePicker.
  • The clock is regional, and so is the time zone. 12 or 24-hour comes from the region part of the tag — en-US and en-GB disagree — so if your user profile stores a region, compose the full tag rather than passing the bare language. The time zone is not the picker’s business at all: the value is wall-clock time, and the time zone of the person entering it — a profile setting, not a workspace one — decides which instant it means.
KeyEnglish (default)comitorMessagesViUsed for
hourHourGiờName of the hour segment and of the hour column
minuteMinutePhútName of the minute segment and column
secondSecondGiâyName of the second segment and column
periodAM/PMBuổiName of the AM/PM segment and column — 12-hour clock only
amAMSAVisible morning text; its first letter is also a shortcut key
pmPMCHVisible afternoon text; same shortcut rule
openPickerOpen time pickerMở bảng chọn giờsr-only name of the clock button
clearClearXoáVisible text of TimePickerClear
tsx
import { DEFAULT_TIME_PICKER_LABELS, comitorMessagesVi } from '@comitor/ui'

// One key, one picker — the other seven still follow the provider.
<TimePicker labels={{ clear: 'Reset' }} value={time} onValueChange={setTime}>…</TimePicker>

// Every key is a plain string, so labels can come straight from a Server Component.
DEFAULT_TIME_PICKER_LABELS.openPicker   // 'Open time picker' — the English table since 3.0.0
comitorMessagesVi.timePicker.openPicker // 'Mở bảng chọn giờ'

What the port guarantees

Behaviour that differs from Dice UI, each one a bug in the upstream component. The port keeps upstream’s structure and part names so it can still be diffed against it.

  • A 24-hour clock has no AM/PM segment. Upstream still drew one — “PM” beside 14:30 — and typing A into it turned 14:30 into 02:30, while its own period column hid itself. Segment and column now follow one rule.
  • Read-only means read-only. With readOnly (or disabled) the columns ignore clicks, as Clear already did. Upstream’s columns never looked at either flag — and the clock button still opens the panel when read-only — so a read-only time could be changed with the mouse.
  • The label names the field. TimePickerLabel carries the id that the group’s aria-labelledby points at. Upstream pointed at an id no element had, so screen readers announced an unnamed group.
  • Every part has a name. Segments are named Hour, Minute, Second and AM/PM; each column is a named listbox whose options carry aria-selected; the clock button has an sr-only name; invalid reaches every segment as aria-invalid. Upstream had none of these.
  • Server and browser draw the same clock. Upstream followed the runtime’s locale, so a Node server (en-US) printed “02” and a Vietnamese browser “14” for the same 14:30 — a hydration mismatch. The clock now comes from the provider or the locale prop, identical on both sides.
  • The initial value is submitted. The hidden form input writes its first value up front; before 2.1.0 an untouched picker posted an empty string.
  • min and max mean what they say. Upstream stored them and nothing else: nothing greyed out, nothing clamped, and 05:30 went through for a shift that may not start before six. The rules are under Between min and max.
  • Tab reaches everything in the panel. Upstream’s Tab cycled between the columns, so a Clear placed inside TimePickerContent could only be clicked. Tab now runs through the columns, landing on each one’s selected value, and on to the rest of the panel.
  • It lines up with Input and DatePicker. h-9 instead of upstream’s h-10, the package’s border roles, a focus ring without offset and a destructive-ink error border. The selected item uses the checked-control pair, --control-on and --control-check, like the selected day in Calendar.

Known limitations

  • min and max only fence in the user. A value the app sets through value or defaultValue — a shift saved before the window changed — is shown as given, and so is a value a new window leaves outside; only its items grey out. Check those in your form layer, as the shift demo does.
  • Steps only thin the columns. A typed value off the grid is accepted and leaves no item highlighted. If only quarter hours are allowed, reject or round in validation.
  • Unfinished values reach your handler, and blanks get filled from the current clock when a typed segment is left. Check for a complete time before saving, and do not autosave on every change.
  • required does not block submission. The flag only reaches the hidden type="hidden" input, which browsers exclude from constraint validation, and no aria-required is set. Mark the field visibly and check it in your submit handler.
  • The panel is never wider than the field. It is capped at the width of the input group it is anchored to. Hour, minute and period need about 10rem; with seconds on a 12-hour clock, about 13rem. A narrower field lets the columns spill past the panel’s edge.
  • Inside react-hook-form’s FormControl, the Slot puts aria-invalid and aria-describedby on the root div and its id nowhere at all — none of it reaches the segments. Pass invalid to the picker and aria-describedby to TimePickerInputGroup, and name it with TimePickerLabel.

Props

TimePicker

PropTypeDefaultDescription
valuestring—Controlled value, always 24-hour: HH:mm, or HH:mm:ss with showSeconds — whatever the clock on screen shows. An empty string means no time.
defaultValuestring—Uncontrolled initial value, same format.
onValueChange(value: string) => void—Fires with the whole string each time a segment commits. Mid-edit values can carry -- for an empty segment (09:--, --:30); clearing every segment emits an empty string.
openboolean—Controlled open state of the panel.
defaultOpenbooleanfalseUncontrolled initial open state.
onOpenChange(open: boolean) => void—Fires when the panel opens or closes.
openOnFocusbooleanfalseOpen the panel whenever a segment receives focus. Focus stays in the segment, and clicks inside the field do not count as clicks outside the panel.
inputGroupClickAction'focus' | 'open''focus'What a click on the empty part of the field does: focus the first segment, or open the panel.
minstring—Earliest allowed time, HH:mm or HH:mm:ss. Panel items with no allowed time are disabled, and a pick or a typed segment that lands outside becomes the nearest allowed time. Later than max, the window crosses midnight (22:00–06:00); without max, it runs to the end of the day. A value the app sets through value or defaultValue is shown as given, never clamped. Enforced since 3.1.0 — before, only stored.
maxstring—Latest allowed time, with the same format and rules as min. Without min, the window starts at 00:00.
hourStepnumber1Interval of the hours listed in the panel. Typing and the arrow keys still move one hour at a time.
minuteStepnumber1Interval of the minutes listed in the panel (15 lists 00, 15, 30, 45). A typed 14:37 is still accepted, and then no item is highlighted.
secondStepnumber1Interval of the seconds listed in the panel.
segmentPlaceholderstring | { hour?, minute?, second?, period? }'--'Text of an empty segment, for all segments or one by one. Its length also sets each segment's width in ch. Emitted values always use -- for a blank, whatever you pass here.
localestringprovider localeBCP 47 tag that decides the clock and nothing else: en, en-US, en-AU and ko give 12-hour; en-GB, vi, de, fr and ja give 24-hour. It does not translate the labels. Without a provider it is en. 2.1.0 hard-coded vi-VN.
labelsPartial<TimePickerLabels>timePicker namespaceOverrides single keys; the rest follow ComitorLocaleProvider, then English. All eight keys are plain strings.
namestring—Name of the hidden input that carries the 24-hour value when the picker sits inside a form. Outside a form no hidden input is rendered.
disabledbooleanfalseDisables every segment, the clock button and Clear. A disabled picker's value is not submitted.
readOnlybooleanfalseSegments cannot be edited, and the columns and Clear ignore clicks. The clock button still opens the panel, so the time can be read there.
requiredbooleanfalseOnly reaches the hidden input, and browsers skip constraint validation for type=hidden — it neither blocks submission nor announces anything. Validate and mark the field yourself.
invalidbooleanfalseDestructive-ink border on the field and aria-invalid on every segment.
showSecondsbooleanfalseSwitches the value format to HH:mm:ss. Render the second segment and TimePickerSecond yourself — they do not appear on their own.
asChildbooleanfalseRender the root as your own element (Radix Slot) instead of a div.

TimePickerInput

PropTypeDefaultDescription
segmentrequired'hour' | 'minute' | 'second' | 'period'—The unit this input edits. A period segment renders nothing on a 24-hour clock.
aria-labelstringlabels[segment]The segment's accessible name. It comes from labels; pass your own to override one segment.
disabledbooleanfalsePer segment, combined with the root flag.
readOnlybooleanfalsePer segment, combined with the root flag.
onKeyDownKeyboardEventHandler—Runs before the built-in handling. Call event.preventDefault() to take over a key — the segment then skips its own behaviour for that event.
...restOmit<ComponentProps<'input'>, 'type' | 'value'>—className, style and the other handlers. Width comes from a CSS variable per segment, derived from segmentPlaceholder.

TimePickerContent

PropTypeDefaultDescription
side'top' | 'right' | 'bottom' | 'left''bottom'Preferred side.
align'start' | 'center' | 'end''start'Aligned to the field's start.
sideOffsetnumber6Gap between the field and the panel, in px.
onOpenAutoFocus(event: Event) => void—Runs first. Without preventDefault, opening focuses the selected value of the first column — or leaves focus in the segment when openOnFocus opened the panel.
...restPopoverContent props—className, collisionPadding, onInteractOutside and the rest. The panel is w-auto but capped at the width of the input group it is anchored to.

TimePickerHour · TimePickerMinute · TimePickerSecond · TimePickerPeriod

PropTypeDefaultDescription
format (TimePickerHour)'numeric' | '2-digit''numeric'How hour items print: 9 or 09.
format (Minute, Second)'numeric' | '2-digit''2-digit'How minute and second items print: 05 or 5.
aria-labelstringlabels.hour / minute / second / periodName of the column's listbox. Each column is a listbox of options with aria-selected.
TimePickerPeriod——No props of its own beyond div props. Renders nothing on a 24-hour clock, like the period segment.
asChildbooleanfalseRender the column as your own element.

TimePickerTrigger · TimePickerClear · TimePickerLabel · TimePickerSeparator

PropTypeDefaultDescription
TimePickerTrigger childrenReactNodeclock icon + sr-only labels.openPickerPass text and that text becomes the button's name — the sr-only span only comes with the default icon. Disabled by the root disabled flag, not by readOnly.
TimePickerClear childrenReactNodelabels.clearSets the value to an empty string. Ignores clicks when the root is disabled or readOnly.
TimePickerLabelComponentProps<'label'> & { asChild? }—Renders a label carrying the id that the group's aria-labelledby points at. Pass htmlFor only if you need it for something else.
TimePickerSeparator childrenReactNode':'Decorative, aria-hidden.

useTimePicker

tsx
import { useTimePicker } from '@comitor/ui'

// Reads the picker's store. Call it from a component rendered INSIDE <TimePicker> —
// anywhere else it throws.
function SelectedTime() {
  const value = useTimePicker((state) => state.value) // also state.open
  return <span className="tabular-nums">{value || 'No time yet'}</span>
}

Accessibility

KeysWhereDoes
0–9SegmentTypes the segment. It commits and moves on after two digits, or after one digit that cannot start a pair (hour 2–9 on a 12-hour clock, 3–9 on a 24-hour clock, minute 6–9).
↑ / ↓SegmentSteps by one and wraps. On an empty segment ↑ starts at 12 (12-hour) or 00, and ↓ at 12, 23 or 59. On the period segment, toggles AM/PM. A step outside min / max lands on the nearest allowed time.
← / →SegmentPrevious / next segment.
A · P · 1 · 2Period segmentSets AM or PM. The first letter of labels.am / labels.pm works too — S and C for SA and CH.
Backspace · DeleteSegmentWith the segment selected (it is, on focus), empties it: 14:30 becomes --:30.
EnterSegmentCommits the typed text and stays. Never submits the form.
EscapeSegmentDrops the segment's edit and leaves the field.
Enter · SpaceClock buttonOpens the panel and focuses the selected value of the first column.
↑ / ↓PanelMoves within a column and selects as it moves, skipping values outside min / max; wraps.
← / →PanelPrevious / next column, wrapping.
Tab / Shift+TabPanelNext / previous column, then on to anything else focusable in the panel — a Clear placed there — in DOM order, wrapping at the ends. Each column is one stop, its selected value. Tab never leaves the panel.
Enter · SpacePanelSelects the focused value.
EscapePanelCloses the panel; focus returns to the clock button.
  • The field is a role="group" named by TimePickerLabel through aria-labelledby. Each segment is a text input named from labels, with inputMode="numeric" (text for the period), autocomplete, autocorrect and spellcheck off, and translate="no" so page translation leaves the digits alone.
  • The clock button is a real button whose name is the sr-only labels.openPicker; its icon is aria-hidden. It carries the package’s focus-visible outline.
  • In the panel each column is a named listbox and each value an option with aria-selected. Selection follows focus on ↑ / ↓ — the arrows move and choose at once, and aria-selected moves with them. Values outside min / max carry aria-disabled and the arrows pass over them.
  • Tab does not leave the panel. It goes through the columns — one stop each, on the selected value — then any other control in the panel, and wraps; Escape is the way out, and focus then returns to the clock button.
  • Forms: the value travels in a hidden input named by name, rendered only when the picker is inside a <form>. Its initial value is posted (since 2.1.0); a disabled picker posts nothing; required is not enforced. Enter in a segment commits the segment and never submits the form.
  • Errors: invalid sets aria-invalid on every segment and a destructive-ink border on the field. The message is yours — point aria-describedby on TimePickerInputGroup at it.
  • Still the app’s job: naming the field, translating that name, checking that the value is complete — and inside its window, when the app set it — marking it required, and deciding which time zone it belongs to.
  • The field and its segments are 16px below md and 14px from md up, like every field since 3.13.4 — 14px at every width before. Leave the size to the package; Input explains why.

Related

  • Date Picker — the other half of an appointment; same height, borders and focus ring, and its date format follows the same provider.
  • Form Section — lays out forms of these. Name the picker with TimePickerLabel rather than FormField’s label: a <label for> cannot point at a group.
  • HR — the attendance week prints clock-in spans like 08:45 – 17:30. Setting a shift, or correcting a missed clock-in by hand, is a job for this field and the window check above.
  • Tasks and CRM — due dates and follow-ups that need a time of day beside the date.
  • Mask Input and Phone Input — the other two form fields from the same port, where region matters as much as language.