mwui
GitHub

Time Picker

A keyboard-first time input with 12/24-hour formats and step granularity.

Locale format

09:30

09:30

24-hour, with seconds

--:--:--

null

Outside business hours

19:45

Installation

Usage

Segmented hour, minute, and optional second fields that behave the way a native date field does — type digits and the caret advances, arrows step, each segment announces itself — without inheriting <input type="time">'s unstyleable UI or its inconsistent behavior across browsers.

import { TimePicker } from "@/components/ui/time-picker";

export function ReminderField() {
  const [time, setTime] = React.useState<string | null>("09:30");

  return <TimePicker value={time} onValueChange={setTime} />;
}

The value is always 24-hour "HH:mm" regardless of what is displayed, and null until every segment is filled. That is the form a time column, an ISO timestamp, and a <input type="hidden"> all want.

Hour cycle

Display follows the viewer's locale by default — a US visitor sees 09:30 AM, a French visitor sees 09:30 — while the value stays 24-hour. Force it when the domain demands one format:

<TimePicker hourCycle={24} withSeconds />

The locale is only readable on the client, so a server render cannot know it. Left unset, the field renders 24-hour on the server and switches to the locale's cycle once mounted — a 12-hour visitor sees the AM/PM segment appear on hydration. Pass hourCycle explicitly to render the same markup on both sides and avoid that shift.

Stepping

step is the minute increment for the arrow keys. Typing digits is never restricted by it, so a 15-minute grid still permits an exact 09:07 when someone types it.

<TimePicker step={15} />

Range

min and max mark the field invalid — data-invalid for styling, aria-invalid on the segments — rather than silently rewriting what the user typed. Clamping input as it is typed makes intermediate values impossible to reach.

<TimePicker min="09:00" max="17:00" />

Props

PropDescription
value / defaultValue / onValueChangeControlled or uncontrolled "HH:mm" string, or null.
hourCycle12 or 24. Defaults to the viewer's locale, resolved after mount.
stepMinute increment for the arrow keys. Defaults to 1.
withSecondsAdd a seconds segment; the value becomes "HH:mm:ss".
min / maxFlag values outside the range as invalid.
readOnlyFocusable and announced, but not editable.
nameEmits a hidden input so the field posts in a plain HTML form.

Keyboard

KeyAction
/ Step the focused segment, wrapping at its bounds.
/ Move between segments.
09Type directly. The caret advances as soon as no further digit fits — 3 in a 24-hour hour segment moves on immediately, 1 waits for a second digit.
Backspace / DeleteClear the segment.
A / PSet AM or PM on the day-period segment.

Each segment is a spinbutton with its own label and value text, so a screen reader announces "hour, 9" rather than reading the whole field as one opaque string. Empty segments read as "Empty" instead of "minus minus".

Composing a range

Two pickers and a date range picker make a datetime range without another component:

<DateRangePicker value={range} onValueChange={setRange} />
<TimePicker value={startTime} onValueChange={setStartTime} aria-label="Start time" />
<TimePicker value={endTime} onValueChange={setEndTime} aria-label="End time" />