# 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"`
- **APG Spinbutton:** <https://www.w3.org/WAI/ARIA/apg/patterns/spinbutton/>
- **Page:** <https://ui.booleanpress.com/components/time-field> · @booleanpress/ui 0.2.0

## 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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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

| 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. |
| A or P | 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.

**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`.
