Time input — About
The decisions behind the component, and the ones you should disagree with deliberately rather than by accident.
Why a string and not a Date
Section titled “Why a string and not a Date”new Date('14:30') // Invalid Datenew Date('2026-01-01T14:30') // a moment that moves with the timezone and with DSTA time of day is not an instant. It has no date and no zone, and the moment it acquires either it
starts moving — across a daylight-saving boundary, 14:30 is not a fixed number of milliseconds
from anything.
So no Date is ever constructed. The component stores three numbers, formats them as HH:mm or
HH:mm:ss, and compares ranges as strings — correct because the format is fixed-width and
big-endian, including across precisions:
import { compareISO } from '@rxova/react-time-input'
compareISO('09:00', '14:30') // -1compareISO('14:30:00', '14:30') // 0 — compared on the shared prefixThere is deliberately no toDate() helper. It cannot be written without inventing a date and a
timezone the caller did not supply.
The value is always 24-hour
Section titled “The value is always 24-hour”Whatever the field displays. A US user sees 02:30 PM; onChange and any form receive 14:30.
One canonical format means a value can be stored, compared and sorted without knowing which locale produced it — and it means the same stored value renders correctly for a US user and a German one without conversion.
Seconds appear in the value only when showSeconds is on, so the canonical form matches the field
the user was actually offered rather than claiming a precision they never had.
Midnight and noon
Section titled “Midnight and noon”The single easiest thing to get wrong in a time field.
| Stored hour | 12-hour display | Day period |
|---|---|---|
| 0 | 12 | AM |
| 1 | 1 | AM |
| 11 | 11 | AM |
| 12 | 12 | PM |
| 13 | 1 | PM |
| 23 | 11 | PM |
The fold has to happen before the period offset is applied, or 12 + 12 is 24. It is wrong in
exactly two cases out of twenty-four, which is why a spot-checking test usually misses it — so the
adversarial suite round-trips all 24 hours rather than sampling:
for (let hour = 0; hour <= 23; hour++) { expect(fromDisplayHour(toDisplayHour(hour, true), toDayPeriod(hour))).toBe(hour)}Two consequences worth knowing:
- Retyping the hour keeps the day period. Changing a 3 PM appointment to 5 must not silently make it 5 AM.
- A period chosen before any hour is remembered, stored as the corresponding midnight or noon hour, so the natural “PM first, then the hour” entry order works.
Locale handling
Section titled “Locale handling”12- or 24-hour, segment order, separators and the AM/PM words all come from Intl.DateTimeFormat.
Every engine ships ICU already, so this costs nothing and is correct for locales we have never
heard of.
import { usesHour12, dayPeriodNames, timePieces } from '@rxova/react-time-input'
usesHour12('en-US') // trueusesHour12('de-DE') // falsedayPeriodNames('es-ES') // ['a. m.', 'p. m.']dayPeriodNames('ja-JP') // ['午前', '午後']Hard-coding “AM”/“PM” would be wrong in most of the world. The day period also accepts the
localised first letter from the keyboard, so a German user typing v for “vorm.” is understood.
A malformed tag falls back to a 24-hour field and warns — Intl throws RangeError on en_US,
and crashing a time field over an underscore is the worse outcome.
Known limitation: digits render in Latin numerals even in locales whose Intl output uses
another numeral system. Editing a numeral system the keyboard does not produce would be worse than
the inconsistency.
minuteStep and secondStep must divide 60. A 7-minute step leaves a 4-minute bucket at the top
of every hour, so arrowing up from :56 lands somewhere the grid does not contain — the step is
refused, 1 is used, and onWarn reports step-invalid.
Steps apply to arrow-key stepping, not typing. A user can type 09:07 under a 15-minute step.
Enforcing the grid on typed input fights them mid-entry; validating the final value is the form’s
job.
Ranges do not wrap past midnight
Section titled “Ranges do not wrap past midnight”min="22:00" max="06:00" is two ranges, not one. Pretending otherwise would make withinRange
lie about everything between. Both bounds are dropped and onWarn says why, in those words.
Accessibility
Section titled “Accessibility”- A
role="group"ofrole="spinbutton"segments — including the day period, which is a bounded value stepped with arrows, which is exactly what a spinbutton is. - The day period announces as its localised word, never as 0 or 1. An E2E test asserts that no
aria-valuetexton the page is a bare digit. - The hour’s
aria-valuemin/maxfollow the clock in use (1–12 or 0–23) andaria-valuenowis the number on the clock face, not the stored hour — so a screen reader announcing “2” beside “PM” is telling the truth about what is displayed. - An empty segment announces its placeholder and carries no
aria-valuenow, rather than claiming a value of 0. - Separators are
aria-hidden; reading “colon” between two named spinbuttons adds nothing. - Arrow keys wrap the value but clamp the focus, so the field never becomes a trap.
- Clearing the hour clears the day period with it, because the period is derived from the hour — showing “AM” beside an empty hour would claim a half the field does not have.
axe (WCAG 2.1 A/AA) runs over the component in the browser suite and over the whole demo page in Chromium, Firefox and WebKit — including a right-to-left locale.
Why all three browsers
Section titled “Why all three browsers”Whether a locale is 12- or 24-hour, and where the day period sits, comes entirely from ICU data — and the three engines ship three different ICU builds. That is the one claim only a real run in each engine can check.
The locale tests assert relationships rather than exact separator characters, which ICU changes between versions.
Diagnostics
Section titled “Diagnostics”onWarn receives { code, prop, received, message } whenever a prop is rejected or coerced.
| Code | Meaning |
|---|---|
value-unparseable | Not a zero-padded 24-hour HH:mm[:ss] |
value-out-of-range | Complete and real, but outside min/max |
min-unparseable / max-unparseable | A bound that is not a real time; it is ignored |
min-after-max | No time can satisfy both; both bounds are dropped |
step-invalid | A step that does not divide 60; 1 is used |
locale-invalid | Intl refused the tag; a 24-hour clock is used |
The value-unparseable message recognises a display format and shows the 24-hour equivalent, so
passing "2:30 PM" tells you to pass "14:30".
With no handler these go to console.warn. The entire path is stripped from production builds,
and the E2E suite asserts that against a real production bundle.
Styling
Section titled “Styling”There is no stylesheet to import.
Custom properties
Section titled “Custom properties”Set them on [data-rx-time-root] or any ancestor:
[data-rx-time-root] { --rx-time-gap: 0.0625rem; --rx-time-segment-padding: 0 0.0625rem; --rx-time-segment-radius: 0.125rem; --rx-time-literal-opacity: 0.7; --rx-time-focus-ring: 2px solid Highlight; --rx-time-focus-ring-offset: 1px;}The focused segment paints a ring by default: a <span role="spinbutton"> gets none from the
browser, and shipping outline: none with nothing in its place leaves a keyboard user unable to
tell which segment they are on. Restyle it through those two properties rather than removing it.
Stable data-* hooks
Section titled “Stable data-* hooks”These are public API, covered by semver.
| Attribute | On | Meaning |
|---|---|---|
data-rx-time-root | wrapper | Always present |
data-complete | wrapper | Every needed segment filled |
data-out-of-range | wrapper | Complete but outside min/max |
data-invalid / data-disabled / data-readonly | wrapper | Mirrors the props |
data-rx-time-segment | segment | hour, minute, second or dayPeriod |
data-placeholder | segment | The segment is empty |
data-focused | segment | The segment has focus |
data-rx-time-value | hidden input | The 24-hour value a form posts |
Form libraries
Section titled “Form libraries”Every recipe below is transcribed from src/__tests__/form.browser.test.tsx, so it is code that
runs on every commit. What a form stores is 24-hour HH:mm even when the field displays 9:30 AM, so a stored time can be compared and sorted without knowing which locale produced it.
Native forms
Section titled “Native forms”With a name, the component emits a hidden input carrying the 24-hour value:
<form action="/booking" method="post"> <TimeInput name="start" label="Start" locale="en-US" /> <button type="submit">Save</button></form>formData.get('start') is '09:30', even though the field showed 9:30 AM.
React Hook Form
Section titled “React Hook Form”onChange emits the 24-hour string (or null), so field.onChange binds directly. It fires only once the time is complete:
<Controller name="start" control={control} render={({ field }) => ( <TimeInput label="Time" value={field.value} onChange={field.onChange} onBlur={field.onBlur} name={field.name} /> )}/>Formik
Section titled “Formik”function Field() { const [field, , helpers] = useField<string | null>('start') return ( <TimeInput label="Time" name="start" value={field.value} onChange={(value) => helpers.setValue(value)} onBlur={() => helpers.setTouched(true)} /> )}onBlur fires when focus leaves the whole field, not when it moves from the hour segment to the minute.
React Final Form
Section titled “React Final Form”<Field name="start"> {({ input }) => ( <TimeInput label="Time" name={input.name} value={input.value === '' ? null : String(input.value)} onChange={input.onChange} onBlur={input.onBlur} /> )}</Field>TanStack Form
Section titled “TanStack Form”<form.Field name="start"> {(field) => ( <TimeInput label="Time" name="start" value={field.state.value} onChange={(value) => field.handleChange(value)} onBlur={field.handleBlur} /> )}</form.Field>React Final Form starts a field as '', which is not a time. The guard above is the binding’s job:
the component takes null for empty.
UI-library recipes
Section titled “UI-library recipes”Keep the segmented spinbuttons and localized day period intact; use the design system for the field’s visible label, helper text, validation, and layout.
shadcn/ui
'use client'
import { TimeField } from '@/components/rxova/time-field'
export function ShadcnTime() {
return (
<TimeField
label="Start time"
description="Enter a local time."
name="startTime"
locale="en-US"
/>
)
}
Radix Themes
'use client'
import { Box, Text } from '@radix-ui/themes'
import { TimeInput } from '@rxova/react-time-input'
export function RadixTime() {
return (
<Box>
<Text as="div" size="2" weight="bold" mb="1">
Start time
</Text>
<TimeInput label="Start time" name="startTime" locale="en-US" />
<Text as="div" size="1" color="gray" mt="1">
Enter a local time.
</Text>
</Box>
)
}
Material UI
'use client'
import FormControl from '@mui/material/FormControl'
import FormHelperText from '@mui/material/FormHelperText'
import FormLabel from '@mui/material/FormLabel'
import { TimeInput } from '@rxova/react-time-input'
export function MuiTime() {
return (
<FormControl>
<FormLabel>Start time</FormLabel>
<TimeInput label="Start time" name="startTime" locale="en-US" />
<FormHelperText>Enter a local time.</FormHelperText>
</FormControl>
)
}
Chakra UI
'use client'
import { Field } from '@chakra-ui/react'
import { TimeInput } from '@rxova/react-time-input'
export function ChakraTime() {
return (
<Field.Root>
<Field.Label>Start time</Field.Label>
<TimeInput label="Start time" name="startTime" locale="en-US" />
<Field.HelperText>Enter a local time.</Field.HelperText>
</Field.Root>
)
}
Mantine
'use client'
import { Input } from '@mantine/core'
import { TimeInput } from '@rxova/react-time-input'
export function MantineTime() {
return (
<Input.Wrapper label="Start time" description="Enter a local time.">
<TimeInput label="Start time" name="startTime" locale="en-US" />
</Input.Wrapper>
)
}
Ant Design
'use client'
import { Form } from 'antd'
import { TimeInput } from '@rxova/react-time-input'
export function AntTime() {
return (
<Form.Item label="Start time" extra="Enter a local time.">
<TimeInput label="Start time" name="startTime" locale="en-US" />
</Form.Item>
)
}