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', }}/>The value is "HH:MM"
Section titled “The value is "HH:MM"”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.
The three labels are required
Section titled “The three labels are required”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.
The locale decides the mask
Section titled “The locale decides the mask”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
Section titled “The panel”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.
Keyboard
Section titled “Keyboard”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. |
react-hook-form
Section titled “react-hook-form”<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".shouldFocusErrorcallsfocus()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;onValueChangestill reportsnull, 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.
Styling contract
Section titled “Styling contract”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. |
Reserved names
Section titled “Reserved names”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.
Custom properties
Section titled “Custom properties”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.
Accessibility
Section titled “Accessibility”- One real control. The field is a single
<input type="text" inputmode="numeric">witharia-haspopup="dialog"; the active segment is the platform’s own text selection, so it reads as text rather than as a widget. A row ofspinbuttons was rejected in turn: it multiplies tab stops and shows no virtual keyboard. aria-expandedis on the trigger button, never on the input.textboxdoes not take it in ARIA 1.2, and an input carrying it fails axe’saria-allowed-attr. The trigger istabindex="-1", so the field is still a single tab stop.- Two listboxes, one roving tab stop each. Every row is a
role="option"witharia-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:30leaves the hour empty — so a complete field is always a real time. The rule behind it is DatePicker’s: a value the mask refuses carriesaria-invalidand commitsnull, never the time that was there before. An explicitaria-invalidof your own wins, includingfalse. - The 24px floor. A row is
max(24px, …)tall, read twice — for the box and for the line that centres in it — so evensize="xs"clears WCAG 2.5.8. - Forced colors: the chosen row turns
Highlight/HighlightText, the sticky heading repaints onCanvas, 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 and don’t
Section titled “Do and don’t”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.