# Time Input (/docs/time-input)





`TimeInput` is a standalone time entry — HOUR / MINUTE (+ SECOND) steppers next to an AM/PM toggle, composed from [`NumberInput`](/docs/number-input) and [`SegmentGroup`](/docs/segment-group). Ark UI has no time-picker machine (only a countdown timer), so this is hand-composed from the kit's own primitives rather than a wrapped Ark part — it's the same time row [`DatePicker`](/docs/date-picker) will embed in its popover once it lands.

```tsx
import { TimeInput } from '@42-central/ui-react/time-input';

<TimeInput label="Meeting time" defaultValue={{ hour: 9, minute: 30 }} />
```

The value is `{ hour, minute, second? } | null` — `hour` is always stored in 24h, regardless of what's displayed. There's no built-in range mode: a "start" / "end" pair reads better as two labelled `TimeInput`s than as one control split down the middle with nothing to tell its two halves apart — see [Start / end pairs](#start--end-pairs).

## Playground [#playground]

<story.Playground />

## 12h vs 24h [#12h-vs-24h]

Display defaults to the active locale's own convention (`Intl.DateTimeFormat`'s `hourCycle`) — the AM/PM segment group only renders in a 12h locale. Override it with `format` (`12 | 24`) or `hourCycle` for the exact Unicode cycle (`"h11" | "h12" | "h23" | "h24"`). The stored `hour` is always 24h either way — only the display and the AM/PM segment change.

<story.Preview variant="24-hour" />

```tsx
// Force 24h regardless of locale
<TimeInput format={24} defaultValue={{ hour: 14, minute: 5 }} />

// Force 12h regardless of locale
<TimeInput format={12} defaultValue={{ hour: 14, minute: 5 }} />
```

## Start / end pairs [#start--end-pairs]

There's no `range` prop. Two `TimeInput`s, each with its own label, reads more clearly than one control with an unlabelled start half and end half — compose them yourself:

<Preview align="block" code="&#x22;<div className=\&#x22;flex gap-4\&#x22;>\n  <TimeInput label=\&#x22;Start time\&#x22; defaultValue={{ hour: 9, minute: 0 }} />\n  <TimeInput label=\&#x22;End time\&#x22; defaultValue={{ hour: 17, minute: 30 }} />\n</div>&#x22;">
  <div className="flex gap-4">
    <TimeInput label="Start time" defaultValue="{ hour: 9, minute: 0 }" />

    <TimeInput label="End time" defaultValue="{ hour: 17, minute: 30 }" />
  </div>
</Preview>

## Seconds & minute step [#seconds--minute-step]

`withSeconds` adds a third stepper; `minuteStep` sets the minute increment (e.g. `15` for quarter-hour scheduling). Every stepper wraps at its bounds (`23 → 0`, `59 → 0`, …) instead of clamping. Typing doesn't wrap: each field behaves like a clock segment, so a digit that would overflow it replaces the field (`10`, then `2` typed at the start, reads `2` — not `210`), and an out-of-range value is clamped when the field loses focus.

<story.Preview variant="With seconds" />

## Sizes [#sizes]

Five sizes — `xs` through `xl` — matching the rest of the family.

<Preview align="block" code="&#x22;<TimeInput size=\&#x22;xs\&#x22; defaultValue={{ hour: 9, minute: 30 }} />\n<TimeInput size=\&#x22;sm\&#x22; defaultValue={{ hour: 9, minute: 30 }} />\n<TimeInput size=\&#x22;md\&#x22; defaultValue={{ hour: 9, minute: 30 }} />\n<TimeInput size=\&#x22;lg\&#x22; defaultValue={{ hour: 9, minute: 30 }} />\n<TimeInput size=\&#x22;xl\&#x22; defaultValue={{ hour: 9, minute: 30 }} />&#x22;">
  <div className="flex w-full max-w-[24rem] flex-col gap-3">
    <TimeInput size="xs" defaultValue="{ hour: 9, minute: 30 }" />

    <TimeInput size="sm" defaultValue="{ hour: 9, minute: 30 }" />

    <TimeInput size="md" defaultValue="{ hour: 9, minute: 30 }" />

    <TimeInput size="lg" defaultValue="{ hour: 9, minute: 30 }" />

    <TimeInput size="xl" defaultValue="{ hour: 9, minute: 30 }" />
  </div>
</Preview>

## Label, description & error [#label-description--error]

`label` / `description` / `error` / `required` come from the built-in [`Field`](/docs/field) wrapper, same as the rest of the family.

<Preview align="block" code="&#x22;<TimeInput\n  label=\&#x22;Meeting time\&#x22;\n  description=\&#x22;Local time for all attendees\&#x22;\n  defaultValue={{ hour: 9, minute: 30 }}\n  required\n/>\n\n<TimeInput\n  label=\&#x22;Meeting time\&#x22;\n  error=\&#x22;Pick a time\&#x22;\n  defaultValue={{ hour: 0, minute: 0 }}\n/>&#x22;">
  <div className="flex w-full max-w-[24rem] flex-col gap-5">
    <TimeInput label="Meeting time" description="Local time for all attendees" defaultValue="{ hour: 9, minute: 30 }" />

    <TimeInput label="Meeting time" error="Pick a time" defaultValue="{ hour: 0, minute: 0 }" />
  </div>
</Preview>

## Controlled [#controlled]

Drive the value from state. `onChange` hands you the next `{ hour, minute, second? }`.

```tsx
'use client';
import { useState } from 'react';
import { TimeInput, type TimeValue } from '@42-central/ui-react/time-input';

export function MeetingTime() {
  const [value, setValue] = useState<TimeValue>({ hour: 9, minute: 30 });
  return <TimeInput label="Meeting time" value={value} onChange={setValue} />;
}
```

## Forms [#forms]

`TimeInput` has no machine-backed hidden part to piggyback on (unlike `Select`'s hidden `<select>`), so `name` renders a hand-rolled hidden `HH:mm[:ss]` input instead — a `<form>` submission and `FormData` pick it up the same way.

```tsx
<form action={save}>
  <TimeInput name="startTime" defaultValue={{ hour: 9, minute: 0 }} />
</form>
```

## API [#api]

<TypeTable
  type="{
  value: { type: '{ hour, minute, second? } | null', description: 'Controlled value.' },
  defaultValue: { type: '{ hour, minute, second? } | null', description: 'Uncontrolled initial value.' },
  onChange: { type: '(value) => void', description: 'Fires with the next value.' },
  format: { type: '12 | 24', description: 'Force 12-hour or 24-hour display, overriding the locale default.' },
  hourCycle: { type: '&#x22;h11&#x22; | &#x22;h12&#x22; | &#x22;h23&#x22; | &#x22;h24&#x22;', description: 'Force a specific hour cycle, overriding the locale default and format.' },
  minuteStep: { type: 'number', default: '1', description: 'Minute stepper increment.' },
  withSeconds: { type: 'boolean', default: 'false', description: 'Add a SECOND field alongside HOUR/MINUTE.' },
  placeholder: { type: 'string', default: '&#x22;--&#x22;', description: 'Placeholder shown in each empty Hour/Minute/Second field.' },
  locale: { type: 'string', description: 'Locale used to resolve 12h vs 24h. Defaults to the active locale context.' },
  size: { type: '&#x22;xs&#x22; | &#x22;sm&#x22; | &#x22;md&#x22; | &#x22;lg&#x22; | &#x22;xl&#x22;', default: '&#x22;md&#x22;', description: 'Field sizing.' },
  variant: { type: '&#x22;default&#x22; | &#x22;unstyled&#x22;', default: '&#x22;default&#x22;', description: 'Shell treatment.' },
  color: { type: 'Color', default: '&#x22;brand&#x22;', description: 'Focus-ring accent.' },
  label: { type: 'ReactNode', description: 'Field label.' },
  description: { type: 'ReactNode', description: 'Field helper text.' },
  error: { type: 'ReactNode', description: 'Field error; sets the invalid state.' },
  required: { type: 'boolean', default: 'false', description: 'Required flag + asterisk.' },
  disabled: { type: 'boolean', default: 'false', description: 'Disable every sub-control.' },
  readOnly: { type: 'boolean', default: 'false', description: 'Full contrast, but rejects edits.' },
  invalid: { type: 'boolean', default: 'false', description: 'Red state, independent of `error`.' },
  name: { type: 'string', description: 'Name for the hidden HH:mm[:ss] input.' },
  classNames: { type: '{ field?, hour?, minute?, second?, period? }', description: 'Per-part class overrides.' },
  translations: {
    type: '{ time?, hour?, minute?, second?, period?, incrementLabel?, decrementLabel? }',
    description: &#x22;Overrides the English screen-reader names: the row's group (`time`, default \&#x22;Time\&#x22;, used when there is no `label`), the sr-only Hour/Minute/Second/Period field names, and the steppers' accessible names (incrementLabel / decrementLabel, defaults \&#x22;Increment\&#x22; / \&#x22;Decrement\&#x22;, forwarded to each field's NumberInput). A partial override keeps the other defaults.&#x22;,
  },
  testId: { type: 'string', description: 'data-testid on the wrapping shell div — there is no single focusable control to route it to (Hour/Minute/Second/Period are separate sub-fields), so a root testid is the practical minimum. See the Testing guide.' },
}"
/>

`TimeInput` has no single focusable control — Hour/Minute/Second/Period are separate sub-fields —
so `testId` lands on the wrapping shell `<div>` rather than any one of them. See the
[Testing](/docs/testing) guide for the full `testId` convention across the kit.

## Accessibility [#accessibility]

* HOUR / MINUTE / SECOND are real `role="spinbutton"` `NumberInput`s, each with its own accessible name (`Hour` / `Minute` / `Second`) and the full stepper keyboard model (`↑` / `↓`, `PageUp` / `PageDown`, `Home` / `End`). The name stays wired even though the label isn't shown — it's visually hidden (`sr-only`), not removed, so screen readers still announce it. The `h` / `m` / `s` suffix next to each value is decorative only (`aria-hidden`), not a replacement accessible name.
* The AM/PM toggle is a labeled `SegmentGroup` (a radio-group under the hood) — arrow keys move between the two segments. Its "Period" label is likewise visually hidden but still the accessible name; the segments' own "AM"/"PM" text is visible and self-descriptive.
* Every stepper wraps at its bounds rather than clamping, so keyboard users can cycle past midnight/the hour without hitting a dead end.
* The row is one `role="group"` named by the field's `label` (through `aria-labelledby`) and described by its `description` / `error`, so a screen reader entering it hears "Meeting time, group" before "Hour". Two `TimeInput`s labelled "Start time" and "End time" are two distinctly named groups. Without a `label` the group falls back to "Time" (`translations.time`), so it's never anonymous. `←` / `→` at the edge of a field's text move to the neighbouring field (and from the last one onto the AM/PM toggle), so the caret crosses the row as if it were one input. Mid-text, the arrows move the caret as usual; right-to-left locales mirror the keys.
* `invalid` / `error` propagate to every sub-control's `aria-invalid`; `label` / `description` / `error` associate through the built-in [`Field`](/docs/field).
