Skip to the content

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 group holding one spinbutton per segment, each with aria-valuenow, aria-valuemin, aria-valuemax and aria-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-labelledby or aria-label. Each segment is named by the provider strings hour, minute, second and dayPeriod, after the group's label; an empty one reads emptySegment.
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.
  • min and max mark 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 Date does.

Keyboard

Keyboard
KeyBehaviour
↑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–9Types into the segment, moving on once it is complete.
AorPOn AM/PM: chooses AM or PM (the first letter of the locale's names).
BackspaceRemoves the segment's last digit; on an empty segment, moves to the previous one.
DeleteEmpties the segment.
HomeSets the segment to its lowest value.
EndSets the segment to its highest value.

API

TimeField

Renders a div and passes it every other prop.

TimeField props
PropTypeDefaultDescription
defaultValueDate | nullThe time it starts with, uncontrolled.
disabledbooleanfalseTakes the segments out of the tab order and stops every change.
fluidbooleanfalseFills the width of its container.
formstringThe id of the form the value belongs to, for a field placed outside it.
hourCycle12 | 2412 (with AM/PM) or 24. Defaults to the locale's.
maxDateThe latest time of day; a later time marks the field invalid.
minDateThe earliest time of day; only its hours, minutes and seconds count. An earlier time marks the field invalid.
namestringSubmits 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.
readOnlybooleanfalseKeeps the segments focusable but stops every change.
referenceDateDateThe day a new time is put on when the field starts empty. Defaults to today.
requiredbooleanfalseSets aria-required on every segment.
showSecondsbooleanfalseAdds a seconds segment.
size"default" | "sm" | "lg"28, 35 or 42 px tall. Defaults to the provider's controlSize.
stepnumber1The minutes the arrow keys move by: 15 steps 00, 15, 30, 45.
valueDate | nullThe 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.

Theme tokens
TokenUsed for