---
id: "packages/active-ui/time-field"
title: "TimeField"
description: "A text field for a time of day on the presentation's clock, with an optional grid and limits, that hands your application a 24-hour time."
url: "/packages/active-ui/time-field"
locale: "en"
revision: "6717ab6bda0db2c7"
---

# TimeField

Use `TimeField` for a time of day without a date: an opening hour, a reminder, a slot. The reader
types the time on the clock of the presentation you pass, 24-hour or 12-hour. Your application
receives `HH:mm` (or `HH:mm:ss`) on the 24-hour clock, or `''`.

**Example: A time on a 24-hour and a 12-hour clock**

Client: Runs entirely in your browser. Source: `examples/ui/time-field.tsx`.

```tsx
// apps/docs/examples/ui/time-field.tsx
'use client';

import {useState} from 'react';

import {
  composePresentationContext,
  toIanaTimeZone,
  toLocalTimeString,
} from '@workspace/active-format';
import {
  TimeField,
  type TimeFieldState,
} from '@workspace/active-ui/components/composed/time-field';
import {
  Field,
  FieldDescription,
  FieldError,
  FieldLabel,
} from '@workspace/active-ui/components/primitives/field';

const ZONE = toIanaTimeZone('Europe/Amsterdam');
const OPENS = toLocalTimeString('08:00');
const CLOSES = toLocalTimeString('18:00');
const H24 = composePresentationContext('en', {timeFormat: 'H24'}, ZONE);
const H12 = composePresentationContext('en', {timeFormat: 'H12'}, ZONE);

const REASONS: Record<'format' | 'range' | 'step', string> = {
  format: 'Write a time such as 9:30.',
  range: 'Choose a time between 08:00 and 18:00.',
  step: 'Choose a quarter of an hour.',
};

function Describe({
  id,
  value,
  state,
}: {
  id: string;
  value: string;
  state: TimeFieldState;
}) {
  return state.status === 'invalid' ? (
    <FieldError id={id}>{REASONS[state.rejection]}</FieldError>
  ) : (
    <FieldDescription id={id}>
      Value: <code>{value || "''"}</code> · state: {state.status}
    </FieldDescription>
  );
}

/**
 * A time of day, typed: on a 24-hour clock with a quarter-hour grid and
 * office hours as limits, and on a 12-hour clock. The value is always
 * `HH:mm` on the 24-hour clock; a time off the grid or outside the limits is
 * invalid and never rounded.
 */
export default function TimeFieldExample() {
  const [meeting, setMeeting] = useState('09:30');
  const [meetingState, setMeetingState] = useState<TimeFieldState>({
    status: 'valid',
  });
  const [reminder, setReminder] = useState('14:05');
  const [reminderState, setReminderState] = useState<TimeFieldState>({
    status: 'valid',
  });
  return (
    <div className="grid w-full max-w-xs gap-6">
      <Field data-invalid={meetingState.status === 'invalid' || undefined}>
        <FieldLabel htmlFor="time-meeting">Meeting (24-hour)</FieldLabel>
        <TimeField
          id="time-meeting"
          value={meeting}
          onValueChange={setMeeting}
          onStateChange={setMeetingState}
          locale={H24}
          step={{unit: 'minute', value: 15}}
          min={OPENS}
          max={CLOSES}
          aria-describedby="time-meeting-state"
        />
        <Describe
          id="time-meeting-state"
          value={meeting}
          state={meetingState}
        />
      </Field>
      <Field data-invalid={reminderState.status === 'invalid' || undefined}>
        <FieldLabel htmlFor="time-reminder">Reminder (12-hour)</FieldLabel>
        <TimeField
          id="time-reminder"
          value={reminder}
          onValueChange={setReminder}
          onStateChange={setReminderState}
          locale={H12}
          aria-describedby="time-reminder-state"
        />
        <Describe
          id="time-reminder-state"
          value={reminder}
          state={reminderState}
        />
      </Field>
    </div>
  );
}
```

## What the composition adds

`TimeField` is an [`Input`](/packages/active-ui/input) plus the time reading of
[active-format](/packages/active-format). It:

- reads `13:05`, `13.05` and, on a 12-hour clock, `1:05 PM`, and shows a valid time in the
  presentation's notation;
- applies a **grid** (`step`, counted from midnight) and **limits** (`min`, `max`): a time off the
  grid or outside the limits is invalid and never rounded;
- keeps **empty, incomplete, invalid and valid** apart, and says why a time is invalid:
  `format`, `range` or `step`.

## Usage

```tsx
import {TimeField} from '@workspace/active-ui/components/composed/time-field';

<TimeField
  id="opens"
  value={value} // 'HH:mm' or ''
  onValueChange={setValue}
  onStateChange={setState}
  locale={presentation}
  step={{unit: 'minute', value: 15}}
/>;
```

`min` and `max` are `LocalTimeString` values: make them with `toLocalTimeString('08:00')` from
active-format.

## States and validation

`onStateChange` reports `{status: 'empty' | 'incomplete' | 'valid'}` or
`{status: 'invalid', rejection: 'format' | 'range' | 'step'}` on every keystroke. Turn the
rejection into your own message. A valid time is shown in its canonical form when the field loses
focus; unusable text stays visible.

When `locale`, `precision`, `step`, `min` or `max` change while someone is typing, the half-typed
text is dropped and the last value is shown in the new form. A step in seconds on a minute field,
or `min` after `max`, throws while rendering: it is a programming error.

## What your application provides

- The presentation, the grid and the limits, and the form binding.
- A visible label and the messages for each rejection.

## Accessibility and keyboard

The field is an ordinary text input; there is no picker. All other input props, such as `id`,
`name`, `disabled` and `aria-describedby`, go to the input.

## API

| Prop | Type | Required | Description |
| --- | --- | --- | --- |
| `locale` | `DatePresentation` | yes | The presentation: the clock (`timeFormat` `H24`/`H12`, else the language's own), the separator and the day-period words. |
| `onValueChange` | `(value: string) => void` | yes | Receives the new time, or `''`. As with `DateField`, `''` means only "cleared": half-typed or invalid text never travels out as a value, so a typo cannot erase a stored time. |
| `value` | `string` | yes | The chosen time of day as a `LocalTimeString` (`HH:mm`, or `HH:mm:ss` at second precision, 24-hour), or `''`. A clock reading: no day, no zone. |
| `max` | `LocalTimeString` | no | The latest allowed time, inclusive. |
| `min` | `LocalTimeString` | no | The earliest allowed time, inclusive. |
| `onStateChange` | `((state: TimeFieldState) => void)` | no | Receives the state of the typed text on every keystroke. |
| `placeholder` | `string` | no | Text in the empty field. Defaults to the input pattern (`HH:mm`, `h:mm a`): a notation, not prose. |
| `precision` | `TimeInputPrecision` | no | `minute` (default) or `second`. |
| `step` | `TimeInputStep` | no | The allowed grid, counted from midnight (`{unit: 'minute', value: 15}`). A time off the grid is `invalid` (`step`) and is never rounded to it. |

**TimeFieldState**

```ts
type TimeFieldState =
  | {status: 'empty' | 'incomplete' | 'valid'}
  | {status: 'invalid'; rejection: 'format' | 'range' | 'step'};
```

The state of the typed time text, as the field reports it.

**TimeFieldProps**

```ts
interface TimeFieldProps extends Omit<
  React.ComponentProps<'input'>,
  'value' | 'defaultValue' | 'onChange' | 'type' | 'min' | 'max' | 'step'
> {
  value: string;
  onValueChange: (value: string) => void;
  onStateChange?: (state: TimeFieldState) => void;
  locale: DatePresentation;
  precision?: TimeInputPrecision;
  step?: TimeInputStep;
  min?: LocalTimeString;
  max?: LocalTimeString;
  placeholder?: string;
}
```

## Related

- [DateField](/packages/active-ui/date-field) and
  [LocalDateTimeField](/packages/active-ui/local-date-time-field)
