Skip to content

TimePicker

A time of day, typed or picked. The field is one real text input whose active segment is the platform's own selection — digits fill it, it jumps on its own, the arrows turn it — and the panel is two columns, hours and minutes, that settle the chosen row to the centre. The value is the string 09:30: Temporal.PlainTime's grammar at minute precision, on a 24-hour clock, with no date and no time zone attached to it.

  • Stable
  • React 19
  • One masked input
  • ISO strings
  • Temporal-ready

Columnas de quince minutos.

<TimePicker
name="cita"
label="Hora de la cita"
locale="es-EC"
labels={{
time: 'Abrir horas',
hours: 'Horas',
minutes: 'Minutos',
}}
/>

One string, always two digits and two digits, always a 24-hour clock, null when the field is empty. It is exactly what Temporal.PlainTime#toString() produces at minute precision, so Temporal.PlainTime.from(value) costs nothing.

There is never a time zone in it, and never a date. A time of day is not an instant: turning one into an instant needs a date and a zone that only the application knows. The DatePicker page has the whole story, including the polyfill and the daylight-saving trap; the short version is Temporal.PlainDate.from(day).toPlainDateTime(Temporal.PlainTime.from(value)), then toZonedDateTime(zone) where the zone is actually known.

If you need the date and the time in one value, reach for DatePicker with granularity="minute" rather than gluing two fields together.

time, hours and minutes are required in the type, not optional strings with English defaults. It is ComboBox’s rationale: the trigger button and the role="dialog" are named by time, and the two role="listbox" columns are named by hours and minutes, so the default configuration would otherwise fail an audit — or announce English inside a Spanish product.

Nothing else needs a prop. The rows are digits, and digits read the same everywhere.

locale is a BCP 47 tag; left out, the platform’s is resolved. It settles the field order and the separators only — the numbers themselves are always Latin digits, zero-padded, on hourCycle: "h23". Pass it explicitly under SSR, or the server and the browser can resolve two different masks.

format replaces the locale’s mask with a pattern of your own, as the TR35 subset — time tokens here: H, HH, mm, with anything that is not a letter as a literal. "HH:mm" is the common one. An unsupported token throws, so the mistake surfaces in development, and so does a pattern that is not exactly one hour and one minute — a date token included.

step is the minutes between rows of the minute column, 5 by default. Values below one or above sixty are pulled back into range; the hour column is always all twenty-four.

A time is both halves, so picking an hour over an empty field takes the top of that hour and leaves the panel open — the minute is what finishes the pick and closes it.

The panel is Popover with popover="manual": in the top layer, placed by CSS anchor positioning, but never light-dismissed. The input sits outside the panel, so the browser would count a click in the field as a click outside and close it. The picker closes it itself instead, with no document listener: when focus leaves the whole control, and on Escape from the field or the panel. Anchor positioning and the top layer are probed together; below that tier the panel falls back to an in-flow absolutely positioned box, where an ancestor with overflow: hidden can clip it.

open, defaultOpen and onOpenChange are the controlled trio. Opening through open leaves focus where the user left it — only the trigger and Alt+↓ move focus into the panel.

In the field:

Key Behavior
Digits Fill the active segment and advance as soon as no further digit could fit: 3 leaves an hour field, 1 waits for a second digit. Past the maximum the segment starts over on the new digit. A segment still waiting for a digit commits nothing until it closes, so rewriting 09 as 14 reaches the form once.
: . - / space Any separator closes a segment still waiting for a digit and moves to the next: 1:05 is five past one. After a segment that closed on its own it does nothing.
↑ / ↓ Turn the active segment by one, wrapping at the ends.
← / → Previous / next segment.
Home / End First / last segment.
Backspace / Delete Empties the active segment.
Tab / Shift+Tab Segment to segment, leaving the field only from its last (or first) one — the native time input’s behaviour.
Enter Closes a segment still waiting for a digit and commits it, then submits the form as usual. Leaving the field does the same.
Alt+↓ Opens the panel and moves focus onto the hour column’s tab stop.

Pasting fills both segments. The ISO 09:30 is read first, whatever order the mask writes; anything else is read one run of digits per segment, so 9.5 is 09:05 and 7 h 45 is 07:45. A run a segment cannot hold, like a 24 for the hour, leaves it empty.

In the panel:

Key Behavior
↑ / ↓ One row. The ends clamp: a time column does not wrap.
← / → The other column. With two of them either arrow means the same thing, which is also what keeps the pair right under dir="rtl".
Home / End Ends of the column.
Enter / Space Picks. An hour keeps the panel open; a minute closes it and returns focus to the input.
Escape Closes and returns focus to the input.
Tab Leaves. Focus leaving the whole control closes the panel.
<Form form={form} onSubmit={onSubmit}>
<TimePicker name="cita" label="Hora de la cita" labels={LABELS} />
</Form>

name is the whole wiring. Each commit reaches the form as a change on the field’s anchor, the path a native input takes, so the form’s mode decides when it validates. Focus leaving the whole control is its blur: it marks the field touched and validates under onBlur and onTouched, while moving into the panel is not a leave.

The value never lives in one native control, so a registered anchor input carries name instead — the strategy ComboBox uses, with the same three details:

  • It is a real text input, not type="hidden". shouldFocusError calls focus() on the registered element, and a hidden input cannot take focus. This one is visually hidden and out of the tab order, and forwards its focus to the field you can see.
  • It owns name; the visible input never has one. A native submit posts "09:30" and never the masked text.
  • A cleared field posts "". A value and its absence have one shape on the wire; onValueChange still reports null, because that is the value model, not the wire format.

Precedence for the value is total and written as a chain, the one ComboBox uses: inside a Form, with a name, the store wins and a value prop is ignored; otherwise value !== undefined wins; then the internal state seeded once by defaultValue. Choose name or value, not both: what the field shows is always what the form sends, and a form field is controlled from outside with form.setValue(name, …). noRhf takes the field out of the form, and then value controls it again. Because it is a chain and not value ?? form ?? default, value={null} really clears the field.

defaultValue becomes the field’s default on mount, and only where the store is still empty: the form stays pristine, a bare reset() comes back to it, and a real defaultValues entry or reset(values) still wins. A form error fills the hint slot only when you passed no error of your own. The field freezes while the form submits unless you passed disabled, and useForm({ disabled: true }) disables it whatever you passed. noRhf turns all of it off for one instance.

With a name and no Form around it the component renders an <input type="hidden"> carrier, so a plain HTML <form> posts the same string with no React involved.

The root class plus its data-* attributes are the public API. The closed field reproduces exactly from them, with no React:

Hand-written markup. Same box, same border, same glyph — what it does not have is the mask, the panel and the two roving columns.

Code
<div class="wayra-time-picker" data-variant="outline" data-color="primary" data-size="md" data-shape="rounded">
<input class="wayra-time-picker-input" type="text" inputmode="numeric" autocomplete="off"
aria-haspopup="dialog" aria-label="Hora de la cita" value="09:30">
<button class="wayra-time-picker-trigger" type="button" tabindex="-1" aria-label="Abrir horas" aria-expanded="false">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5.2l3.4 2"/></svg>
</button>
</div>
Attribute Values
data-variant outline (default), filled, soft, ghost
data-color the eight roles; primary when omitted
data-size xs, sm, md, lg, xl
data-shape rounded, rectangle — no pill, the narrowing ComboBox makes, because the trigger needs a straight edge to sit against
data-open "true" while the panel is open; absent otherwise

The parts are .wayra-time-picker-input, -trigger, -anchor (the visually hidden registered input), -panel — which also wears .wayra-popover .wayra-overlay — and inside it -column (carrying data-unit="hour" or data-unit="minute"), -heading, -options (role="listbox") and -option (role="option", with aria-selected).

unstyled drops the design-system classes — the root’s and every part’s — and keeps every data-*, role and aria-* attribute.

The signature detail is the pair of magnets. Each column is scroll-snap-type: block proximity with scroll-snap-align: center on its rows, so the chosen hour and the chosen minute settle to the middle of their column instead of stopping wherever the flick ended. Opening the panel scrolls both selections to centre before you touch anything. The heading above each column is sticky and aria-hidden: it is a sibling of the listbox, never a child, because role="listbox" does not allow one.

TimePickerProps extends ComponentProps<"input"> minus value, defaultValue, onChange, size, color, width, height, name, children, type, inputMode and autoComplete — the first eight redefined below, the last three pinned by the component.

Prop Type Default Description
value string | null — Controlled, "HH:MM". null clears. Ignored inside a Form with a name: the store wins.
defaultValue string | null — Seeds once, on mount. Outside a Form, ignored once value is given.
onValueChange (value: string | null) => void — Fires on every commit, including a rewritten segment of a filled field.
locale string the platform’s Field order and separators of the mask.
format string the locale’s TR35 subset, time tokens: H, HH, mm. An unsupported token, or a pattern that is not a time, throws.
step number 5 Minutes between rows of the minute column. Clamped to 1–60.
open / defaultOpen / onOpenChange boolean / boolean / (open) => void false The panel’s controlled trio.
labels { time, hours, minutes } — required Names the trigger and dialog, and the two columns.
variant "outline" | "filled" | "soft" | "ghost" "outline" As on Input.
color WayraColor "primary" Edge, trigger, ring, and the fill on the chosen row.
size "xs" | "sm" | "md" | "lg" | "xl" "md" The shared control ladder.
shape "rounded" | "rectangle" "rounded" No pill.
readOnly boolean false Focusable, copyable; the panel never opens.
disabled boolean — Freezes field, trigger and panel. A submitting Form sets it; a disabled one sets it whatever you pass.
noRhf boolean false Opts this instance out of the surrounding Form.
label, labelPlacement, helper, error, id, name, unstyled, className, ref The field block, identical to Input. ref reaches the visible input.

variant="dial" — the analogue clock face — and hourCycle="h12" are reserved, not props. They are frozen now so neither arrives later as a breaking rename, and nothing dead ships in the meantime.

This component has no public knob of its own. Its box belongs to the field family and its panel to the overlay family, so it is themed by the tokens the README already documents and Theming explains: --wayra-radius-field and --wayra-border for the box, --wayra-size-field for the ladder every size step is a multiple of, --wayra-color-base-100 and --wayra-color-base-content for the surfaces, and --wayra-tone / --wayra-tone-content set on the element for a colour outside the eight roles.

Everything else it reads is --_wayra-*: private, unregistered, and free to change in a patch. That includes every measurement it shares with a menu row, which is deliberate — a row here and a ComboBox option are the same object at every size, and one shared private name is what keeps them from drifting apart.

  • One real control. The field is a single <input type="text" inputmode="numeric"> with aria-haspopup="dialog"; the active segment is the platform’s own text selection, so it reads as text rather than as a widget. A row of spinbuttons was rejected in turn: it multiplies tab stops and shows no virtual keyboard.
  • aria-expanded is on the trigger button, never on the input. textbox does not take it in ARIA 1.2, and an input carrying it fails axe’s aria-allowed-attr. The trigger is tabindex="-1", so the field is still a single tab stop.
  • Two listboxes, one roving tab stop each. Every row is a role="option" with aria-selected; the column heading sits outside the listbox because the role forbids the child, and the listbox carries the same string as its accessible name.
  • Impossible times are refused as you type. An hour segment will not take a 24 — the digit that would overflow starts the segment over instead, and a pasted 24:30 leaves the hour empty — so a complete field is always a real time. The rule behind it is DatePicker’s: a value the mask refuses carries aria-invalid and commits null, never the time that was there before. An explicit aria-invalid of your own wins, including false.
  • The 24px floor. A row is max(24px, …) tall, read twice — for the box and for the line that centres in it — so even size="xs" clears WCAG 2.5.8.
  • Forced colors: the chosen row turns Highlight / HighlightText, the sticky heading repaints on Canvas, and the focus outline is drawn last so the fill cannot cover it.
  • Reduced motion makes the row highlights instant and turns the panel’s entrance into a 160ms fade in place. The snap is a scroll behaviour, not an animation, so it stays.

Do

A time of day. Two segments, a 24-hour clock, and a column per half.

Don't

A duration is not a time of day. 02:30 here means half past two in the morning, and no amount of labelling makes the panel offer “2 h 30 min”. Use two number fields, or minutes.