ComponentsForm
Time field
A time typed or stepped one part at a time: hours, minutes, optional seconds and AM/PM.
Import
import { TimeField } from "@booleanpress/ui/time-field"Usage
TimeField is a group of segments, one per part of the time, in the order and with the separators of the provider's locale; each segment is a spin button. It needs no peer package. Name it with aria-labelledby pointing at a visible label, or aria-label: a label's htmlFor cannot name a group.
import { useState } from "react"
import { Label } from "@booleanpress/ui/label"
import { TimeField } from "@booleanpress/ui/time-field"
export function DigestTime() {
const [time, setTime] = useState<Date | null>(null)
return (
<div className="flex flex-col gap-2">
<Label id="digest-time">Send the daily digest at</Label>
<TimeField aria-labelledby="digest-time" value={time} onValueChange={setTime} />
</div>
)
}It is uncontrolled with defaultValue, or controlled with value and onValueChange. The value is a Date whose hours and minutes are read and written in the provider's timeZone; a time chosen in an empty field goes on the day of referenceDate (today by default). onValueChange fires once every segment is filled, and with null when a filled field loses a segment.
Digits fill the focused segment and move on once no other digit could follow ("7" in the hour moves on at once, "1" waits for a second digit). The arrow keys step a segment and wrap; step sets the minutes they move by. hourCycle forces a 12- or 24-hour clock (the locale's by default); showSeconds adds seconds. min and max are times of day: a time outside them marks the field invalid, without changing it. size, variant="filled", fluid, disabled and aria-invalid follow the other fields. name submits HH:mm (or HH:mm:ss) in a hidden input.
Examples
Basic
Hours, minutes and AM/PM in the locale's order, named by its label.
import { Label } from "@booleanpress/ui/label"
import { TimeField } from "@booleanpress/ui/time-field"
export default function TimeFieldBasic() {
return (
<div className="flex flex-col gap-2">
<Label id="digest-time-label">Send the daily digest at</Label>
<TimeField aria-labelledby="digest-time-label" defaultValue={new Date(2026, 9, 14, 9, 30)} />
</div>
)
}24-hour
hourCycle={24} drops AM/PM; the hour runs from 00 to 23.
import { Label } from "@booleanpress/ui/label"
import { TimeField } from "@booleanpress/ui/time-field"
export default function TimeField24Hour() {
return (
<div className="flex flex-col gap-2">
<Label id="cutoff-label">Daily sending cut-off</Label>
<TimeField aria-labelledby="cutoff-label" hourCycle={24} defaultValue={new Date(2026, 9, 14, 17, 45)} />
</div>
)
}Seconds
showSeconds adds a seconds segment.
import { Label } from "@booleanpress/ui/label"
import { TimeField } from "@booleanpress/ui/time-field"
export default function TimeFieldSeconds() {
return (
<div className="flex flex-col gap-2">
<Label id="retry-label">Retry the webhook at</Label>
<TimeField aria-labelledby="retry-label" hourCycle={24} showSeconds defaultValue={new Date(2026, 9, 14, 8, 15, 30)} />
</div>
)
}Step (15 min)
step={15} makes the arrow keys move the minutes through 00, 15, 30 and 45.
import { Label } from "@booleanpress/ui/label"
import { TimeField } from "@booleanpress/ui/time-field"
export default function TimeFieldStep() {
return (
<div className="flex flex-col gap-2">
<Label id="slot-label">Support call slot (every 15 minutes)</Label>
<TimeField aria-labelledby="slot-label" step={15} defaultValue={new Date(2026, 9, 14, 14, 0)} />
</div>
)
}Min and max
min and max bound the time of day; 18:30 is outside 9:00–17:00, so the field is invalid.
import { useState } from "react"
import { Label } from "@booleanpress/ui/label"
import { TimeField } from "@booleanpress/ui/time-field"
export default function TimeFieldMinMax() {
const [time, setTime] = useState<Date | null>(new Date(2026, 9, 14, 18, 30))
const late = time !== null && (time.getHours() > 17 || (time.getHours() === 17 && time.getMinutes() > 0))
return (
<div className="flex flex-col gap-2">
<Label id="office-label">Callback time (9:00 to 17:00)</Label>
<TimeField
aria-labelledby="office-label"
aria-describedby="office-hint"
min={new Date(2026, 9, 14, 9, 0)}
max={new Date(2026, 9, 14, 17, 0)}
value={time}
onValueChange={setTime}
/>
<p id="office-hint" className={late ? "text-sm text-destructive-strong" : "text-sm text-muted-foreground"}>
{late ? "Choose a time within office hours." : "Within office hours."}
</p>
</div>
)
}Sizes
sm is 28 px tall with 12 px text, default 35 px with 14 px, lg 42 px with 16 px.
import { TimeField } from "@booleanpress/ui/time-field"
const time = new Date(2026, 9, 14, 9, 30)
export default function TimeFieldSizes() {
return (
<div className="flex flex-col items-center gap-3">
<TimeField size="sm" aria-label="Small" defaultValue={time} />
<TimeField aria-label="Default" defaultValue={time} />
<TimeField size="lg" aria-label="Large" defaultValue={time} />
</div>
)
}Filled
variant="filled" draws the grey --field-filled fill.
import { Label } from "@booleanpress/ui/label"
import { TimeField } from "@booleanpress/ui/time-field"
export default function TimeFieldFilled() {
return (
<div className="flex flex-col gap-2">
<Label id="report-time-label">Weekly report time</Label>
<TimeField aria-labelledby="report-time-label" variant="filled" defaultValue={new Date(2026, 9, 14, 7, 0)} />
</div>
)
}Disabled
The segments leave the tab order and cannot change.
import { Label } from "@booleanpress/ui/label"
import { TimeField } from "@booleanpress/ui/time-field"
export default function TimeFieldDisabled() {
return (
<div className="flex flex-col gap-2">
<Label id="backup-time-label">Nightly backup (set by your host)</Label>
<TimeField aria-labelledby="backup-time-label" disabled defaultValue={new Date(2026, 9, 14, 2, 0)} />
</div>
)
}Invalid
aria-invalid draws the error edge and sets aria-invalid on every segment; aria-describedby reads the message.
import { Label } from "@booleanpress/ui/label"
import { TimeField } from "@booleanpress/ui/time-field"
export default function TimeFieldInvalid() {
return (
<div className="flex flex-col gap-2">
<Label id="reminder-time-label">Reminder time</Label>
<TimeField aria-labelledby="reminder-time-label" aria-invalid aria-describedby="reminder-time-error" />
<p id="reminder-time-error" className="text-sm text-destructive-strong">
Enter the time the reminder goes out.
</p>
</div>
)
}With a form
name submits the time as HH:mm; the form's Reset puts defaultValue back.
import * as React from "react"
import { Button } from "@booleanpress/ui/button"
import { Label } from "@booleanpress/ui/label"
import { TimeField } from "@booleanpress/ui/time-field"
export default function TimeFieldWithForm() {
const [sent, setSent] = React.useState("")
return (
<form
className="flex w-full max-w-sm flex-col gap-3"
onSubmit={(event) => {
event.preventDefault()
setSent([...new FormData(event.currentTarget)].map(([key, value]) => `${key} = ${value}`).join(", "))
}}
>
<div className="flex flex-col gap-2">
<Label id="send-at-label">Send at</Label>
<TimeField aria-labelledby="send-at-label" name="time" defaultValue={new Date(2026, 9, 5, 9, 30)} />
</div>
<div className="flex gap-2">
<Button type="submit">Submit</Button>
<Button type="reset" variant="outline">
Reset
</Button>
</div>
<pre className="overflow-x-auto rounded-md bg-muted px-3 py-2 font-mono text-xs whitespace-pre-wrap text-foreground">{sent ? `Sent: ${sent}` : "Press Submit to see what the form sends."}</pre>
</form>
)
}Read-only
readOnly shows the time and keeps the segments focusable, but no change is made.
import { Label } from "@booleanpress/ui/label"
import { TimeField } from "@booleanpress/ui/time-field"
export default function TimeFieldReadOnly() {
return (
<div className="flex flex-col gap-2">
<Label id="report-time-label">Weekly report sent at</Label>
<TimeField aria-labelledby="report-time-label" readOnly defaultValue={new Date(2026, 9, 14, 8, 30)} />
</div>
)
}Accessibility
- Semantics
- A
groupholding onespinbuttonper segment, each witharia-valuenow,aria-valuemin,aria-valuemaxandaria-valuetext("Empty" while unfilled, "PM" for AM/PM). The separators are hidden from screen readers. The segments are laid out left to right in every direction, in the locale's order. - Labels
- Name the group with
aria-labelledbyoraria-label. Each segment is named by the provider stringshour,minute,secondanddayPeriod, after the group's label; an empty one readsemptySegment. - Focus
- Each segment is a tab stop; the left and right arrows also move between them. A filled segment moves focus to the next. A press on the field's padding focuses the first empty segment.
- Known limits
- On a phone, the segments are editable text so the number keyboard opens; typing goes through the same rules as a key press.
minandmaxmark the field invalid; they do not stop the arrows or clamp the value.- The value carries a date as well as the time; read only its hours and minutes, in the provider's time zone.
- In right-to-left text a time is written with AM/PM before the hours ("م ٠٩:٣٠" in Arabic); the segments keep the locale's left-to-right order, AM/PM last.
- A time that does not happen on its day (in the hour the clocks go forward) becomes the time just after the jump, as a
Datedoes.
Keyboard
| Key | Behaviour |
|---|---|
| ↑ | Adds one (or step minutes) to the segment, wrapping from the highest value to the lowest; AM/PM switches. |
| ↓ | Takes one (or step minutes) from the segment, wrapping from the lowest to the highest. |
| → | Moves to the next segment. |
| ← | Moves to the previous segment. |
| 0–9 | Types into the segment, moving on once it is complete. |
| AorP | On AM/PM: chooses AM or PM (the first letter of the locale's names). |
| Backspace | Removes the segment's last digit; on an empty segment, moves to the previous one. |
| Delete | Empties the segment. |
| Home | Sets the segment to its lowest value. |
| End | Sets the segment to its highest value. |
API
TimeField
Renders a div and passes it every other prop.
| Prop | Type | Default | Description |
|---|---|---|---|
defaultValue | Date | null | The time it starts with, uncontrolled. | |
disabled | boolean | false | Takes the segments out of the tab order and stops every change. |
fluid | boolean | false | Fills the width of its container. |
form | string | The id of the form the value belongs to, for a field placed outside it. | |
hourCycle | 12 | 24 | 12 (with AM/PM) or 24. Defaults to the locale's. | |
max | Date | The latest time of day; a later time marks the field invalid. | |
min | Date | The earliest time of day; only its hours, minutes and seconds count. An earlier time marks the field invalid. | |
name | string | Submits the time as HH:mm (or HH:mm:ss) in a hidden input of this name. | |
onValueChange | ((value: Date | null) => void) | Called with the new Date once every segment is filled, and with null when a filled field loses a segment. | |
readOnly | boolean | false | Keeps the segments focusable but stops every change. |
referenceDate | Date | The day a new time is put on when the field starts empty. Defaults to today. | |
required | boolean | false | Sets aria-required on every segment. |
showSeconds | boolean | false | Adds a seconds segment. |
size | "default" | "sm" | "lg" | 28, 35 or 42 px tall. Defaults to the provider's controlSize. | |
step | number | 1 | The minutes the arrow keys move by: 15 steps 00, 15, 30, 45. |
value | Date | null | The time, when you control it: a Date whose hours and minutes are read in the provider's time zone. | |
variant | "default" | "filled" | filled fills the field grey. Defaults to the provider's fieldVariant. |
Every part takes className, merged with its defaults by cn(), and ref, which reaches the element it renders.
Data attributes: .
Provider strings: hour, minute, second, dayPeriod, day, month, year, emptySegment (BooleanUIProvider's strings).
Theming
The field is Input's look (--field, --control, --ring); the focused segment is --primary with --primary-foreground text, like selected text; empty segments are --muted-foreground.
The tokens its classes read. Change their values in your theme, and it follows.
| Token | Used for |
|---|