---
id: "packages/active-ui/local-date-time-field"
title: "LocalDateTimeField"
description: "A date and a time as one wall-clock value without a zone, with the zone it is read in shown beside it."
url: "/packages/active-ui/local-date-time-field"
locale: "en"
revision: "936629a5d2e78004"
---

# LocalDateTimeField

Use `LocalDateTimeField` for a moment that a person writes as a date and a clock time: a planned
send, the start of a meeting. It combines a [DateField](/packages/active-ui/date-field) and a
[TimeField](/packages/active-ui/time-field). The value is a wall clock, `YYYY-MM-DDTHH:mm`, with no
zone: turning it into an instant is your application's work.

**Example: A date and a time without a zone**

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

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

import {useState} from 'react';

import {
  composePresentationContext,
  toIanaTimeZone,
  toLocalDateTimeString,
} from '@workspace/active-format';
import {
  LocalDateTimeField,
  type LocalDateTimeFieldState,
} from '@workspace/active-ui/components/composed/local-date-time-field';
import {
  Field,
  FieldDescription,
  FieldError,
  FieldLabel,
} from '@workspace/active-ui/components/primitives/field';

const TODAY = '2026-10-20';
const TOMORROW = toLocalDateTimeString('2026-10-21T00:00');
const PRESENTATION = composePresentationContext(
  'en',
  {},
  toIanaTimeZone('Europe/Amsterdam'),
);

/**
 * A date and a time together, as a wall-clock value without a zone
 * (`YYYY-MM-DDTHH:mm`). The zone it is read in is shown beside it; turning
 * it into a moment is the application's work. A date without a time is
 * incomplete and never travels out as a value.
 */
export default function LocalDateTimeFieldExample() {
  const [value, setValue] = useState('2026-11-02T09:00');
  const [state, setState] = useState<LocalDateTimeFieldState>({
    status: 'valid',
  });
  const invalid = state.status === 'invalid';
  return (
    <Field className="w-full max-w-sm" data-invalid={invalid || undefined}>
      <FieldLabel id="send-label" htmlFor="send-at">
        Send the statements at
      </FieldLabel>
      <LocalDateTimeField
        id="send-at"
        aria-labelledby="send-label"
        aria-describedby="send-at-state"
        value={value}
        onValueChange={setValue}
        onStateChange={setState}
        locale={PRESENTATION}
        today={TODAY}
        min={TOMORROW}
        step={{unit: 'minute', value: 15}}
        openCalendarLabel="Choose a date"
        dateLabel="Date"
        timeLabel="Time"
        timeZoneLabel="Europe/Amsterdam"
        aria-invalid={invalid || undefined}
      />
      {invalid ? (
        <FieldError id="send-at-state">
          {state.rejection === 'range'
            ? 'Choose a moment from tomorrow on.'
            : state.rejection === 'step'
              ? 'Choose a quarter of an hour.'
              : 'Write a date and a time.'}
        </FieldError>
      ) : (
        <FieldDescription id="send-at-state">
          Value: <code>{value || "''"}</code> · state: {state.status}
        </FieldDescription>
      )}
    </Field>
  );
}
```

## What the composition adds

- **One value for two parts.** Only a complete, valid date and time is a value; clearing both parts
  gives `''`. A date without a time is `incomplete` and never travels out.
- **One state for the pair**, in the shape of [`TimeField`](/packages/active-ui/time-field)'s: `empty`, `incomplete`, `valid`, or
  `invalid` with `format`, `range` or `step`. The limits `min` and `max` apply to the whole value,
  the grid `step` to the time.
- **A zone label** beside the time, linked to both parts as a description. The field neither
  chooses nor converts a zone.

## Usage

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

<LocalDateTimeField
  id="send-at"
  aria-labelledby="send-at-label"
  value={value} // 'YYYY-MM-DDTHH:mm' or ''
  onValueChange={setValue}
  onStateChange={setState}
  locale={presentation}
  today={todayIsoDate(timeZone)}
  openCalendarLabel="Choose a date"
  dateLabel="Date"
  timeLabel="Time"
  timeZoneLabel={timeZone}
/>;
```

## States and validation

While the state is `incomplete` or `invalid`, the visible text is not the value the form holds.
A form that saves must listen to `onStateChange` and refuse to submit with a message; otherwise a
broken edit saves the previous value unnoticed. A new `value` from outside, such as a form reset,
ends the edit and reports the state again.

## What your application provides

- The conversion to and from an instant, in the owner's zone and under its policy for a skipped or
  repeated clock time (`wallClockToInstant` and `instantToWallClock` in active-temporal).
- `today`, the presentation, the translated names of both parts and of the calendar button, and the
  zone label when the zone matters to the reader.

## Accessibility and keyboard

- The pair is a `role="group"` named by `aria-labelledby`: pass the `id` of the visible label.
- `id` lands on the date input, so `FieldLabel htmlFor` focuses it; each part is named by
  `dateLabel` and `timeLabel`.
- The date input, the calendar button and the time input are three tab stops.

## API

| Prop | Type | Required | Description |
| --- | --- | --- | --- |
| `dateLabel` | `string` | yes | Accessible name of the date part (`Date`). Supplied translated. |
| `locale` | `DatePresentation` | yes | The presentation of both parts: date notation and calendar words, and the clock (`timeFormat` `H24`/`H12`, else the language's). The same presentation the rest of the page formats with. |
| `onValueChange` | `(value: string) => void` | yes | Receives the new value, or `''`. Only a complete, valid date AND time is a value; clearing BOTH parts is `''`. A half-filled pair (a date without a time) or invalid text never travels out, so it cannot erase a stored value; `onStateChange` reports it instead. |
| `openCalendarLabel` | `string` | yes | Accessible name of the calendar button. Supplied translated. |
| `timeLabel` | `string` | yes | Accessible name of the time part (`Time`). Supplied translated. |
| `today` | `string` | yes | The owner's calendar day, as for `DateField.today`. |
| `value` | `string` | yes | The chosen wall-clock value as a `LocalDateTimeString` (`YYYY-MM-DDTHH:mm`, with seconds at second precision), or `''`.  **A wall clock, not a moment.** No zone travels with it and this field invents none. The owner turns it into an instant in ITS zone, under ITS policy for a skipped or repeated clock time (`wallClockToInstant` in `@workspace/active-temporal`), and reads a stored instant back with `instantToWallClock` in the same zone. |
| `aria-describedby` | `string` | no | Lands on both inputs. |
| `aria-invalid` | `boolean` | no | Marks both parts as invalid. |
| `aria-labelledby` | `string` | no | Lands on the group, so the field's visible label names the pair. |
| `className` | `string` | no | Lands on the group. |
| `disabled` | `boolean` | no | Disables both parts and the calendar button. |
| `id` | `string` | no | Lands on the DATE input, so `<FieldLabel htmlFor={id}>` focuses it. |
| `max` | `LocalDateTimeString` | no | The latest allowed value, inclusive. |
| `min` | `LocalDateTimeString` | no | The earliest allowed value, inclusive. |
| `name` | `string` | no | The base of the inputs' names: `<name>.date` and `<name>.time`. |
| `onBlur` | `((event: FocusEvent<HTMLInputElement>) => void)` | no | Called when either part loses focus. |
| `onStateChange` | `((state: LocalDateTimeFieldState) => void)` | no | Receives the state of the pair on every edit, and again when a new `value` from outside (a form reset) ends an edit.  **Three things that are not the same.** The *edit* is the text in the two parts; the *value* is the last complete, valid pair (`value`), which an edit in progress never overwrites; and whether the form may be *submitted* is the host's question. While the state is `incomplete` or `invalid`, the visible text is not the value the form holds, so a form that saves must listen here and refuse to submit with a field message: without that, a cleared or broken edit saves the previous value unnoticed. |
| `precision` | `TimeInputPrecision` | no | `minute` (default) or `second`. |
| `readOnly` | `boolean` | no | Makes both parts read-only and the calendar button unavailable. |
| `required` | `boolean` | no | Marks both parts as required for assistive technology. |
| `step` | `TimeInputStep` | no | The allowed time grid, counted from midnight of each day (`{unit: 'minute', value: 15}`). Never rounded to: a time off the grid is `invalid` (`step`). A step in seconds needs `precision: 'second'`. |
| `timeZoneLabel` | `string` | no | Text shown after the time, for example the zone the owner reads the value in (`Europe/Amsterdam`). Supplied by the host when the zone matters to the reader; the field neither chooses nor converts a zone. It is linked to both parts as a description. |

**LocalDateTimeFieldState**

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

The state of the two parts together, as the field reports it.

**LocalDateTimeFieldProps**

```ts
interface LocalDateTimeFieldProps {
  value: string;
  onValueChange: (value: string) => void;
  onStateChange?: (state: LocalDateTimeFieldState) => void;
  locale: DatePresentation;
  today: string;
  openCalendarLabel: string;
  dateLabel: string;
  timeLabel: string;
  precision?: TimeInputPrecision;
  step?: TimeInputStep;
  min?: LocalDateTimeString;
  max?: LocalDateTimeString;
  timeZoneLabel?: string;
  disabled?: boolean;
  readOnly?: boolean;
  required?: boolean;
  id?: string;
  'aria-labelledby'?: string;
  'aria-describedby'?: string;
  'aria-invalid'?: boolean;
  name?: string;
  onBlur?: (event: React.FocusEvent<HTMLInputElement>) => void;
  className?: string;
}
```
