# Time Picker

A keyboard-first time input with 12/24-hour formats and step granularity.

## Installation

```bash
npx shadcn@latest add https://mwui.vercel.app/r/time-picker.json
```

[Registry JSON](https://mwui.vercel.app/r/time-picker.json)

## Preview

```tsx
import * as React from "react";

import { TimePicker } from "@/components/ui/time-picker";

export function Preview() {
  const [time, setTime] = React.useState<string | null>("09:30");
  const [precise, setPrecise] = React.useState<string | null>(null);

  return (
    <div className="flex w-full max-w-sm flex-col gap-6 text-sm">
      <div className="flex flex-col gap-2">
        <p className="font-medium">Locale format</p>
        <TimePicker value={time} onValueChange={setTime} step={15} aria-label="Reminder time" />
        <p className="font-mono text-xs text-muted-foreground">{time ?? "null"}</p>
      </div>

      <div className="flex flex-col gap-2">
        <p className="font-medium">24-hour, with seconds</p>
        <TimePicker
          value={precise}
          onValueChange={setPrecise}
          hourCycle={24}
          withSeconds
          aria-label="Exact time"
        />
        <p className="font-mono text-xs text-muted-foreground">{precise ?? "null"}</p>
      </div>

      <div className="flex flex-col gap-2">
        <p className="font-medium">Outside business hours</p>
        <TimePicker defaultValue="19:45" min="09:00" max="17:00" aria-label="Meeting time" />
      </div>
    </div>
  );
}
```


## Source

### ui/time-picker.tsx

```tsx
"use client";

import { IconClock } from "@tabler/icons-react";
import * as React from "react";

import { useControllableState } from "@/hooks/use-controllable-state";
import { useMounted } from "@/hooks/use-mounted";
import { cn } from "@/lib/utils";

type TimePickerHourCycle = 12 | 24;

type TimeParts = {
  hour: number | null;
  minute: number | null;
  second: number | null;
};

type TimePickerProps = Omit<React.ComponentProps<"div">, "defaultValue" | "onChange" | "value"> & {
  value?: string | null;
  defaultValue?: string | null;
  onValueChange?: (value: string | null) => void;
  hourCycle?: TimePickerHourCycle;
  step?: number;
  withSeconds?: boolean;
  min?: string;
  max?: string;
  disabled?: boolean;
  readOnly?: boolean;
  name?: string;
  "aria-label"?: string;
};

function TimePicker({
  value,
  defaultValue = null,
  onValueChange,
  hourCycle,
  step = 1,
  withSeconds = false,
  min,
  max,
  disabled = false,
  readOnly = false,
  name,
  className,
  "aria-label": ariaLabel = "Time",
  ...props
}: TimePickerProps) {
  const [time, setTime] = useControllableState<string | null>({
    value,
    defaultValue,
    onChange: onValueChange,
  });

  const mounted = useMounted();
  const resolvedHourCycle = hourCycle ?? (mounted ? getLocaleHourCycle() : 24);
  const [draft, setDraft] = React.useState<TimeParts>(() => parseTime(time));
  const [syncedTime, setSyncedTime] = React.useState(time);

  if (time !== syncedTime) {
    setSyncedTime(time);
    setDraft(parseTime(time));
  }

  const parts = draft;

  const containerRef = React.useRef<HTMLDivElement>(null);
  const typing = React.useRef<{ segment: string; digits: string; at: number } | null>(null);

  const commit = (next: TimeParts) => {
    setDraft(next);

    const complete =
      next.hour !== null && next.minute !== null && (!withSeconds || next.second !== null);
    const nextTime = complete ? formatTime(next, withSeconds) : null;

    setSyncedTime(nextTime);
    if (nextTime !== time) setTime(nextTime);
  };

  const focusSibling = (from: HTMLElement, direction: 1 | -1) => {
    const segments = Array.from(
      containerRef.current?.querySelectorAll<HTMLElement>("[data-segment]") ?? [],
    );
    const index = segments.indexOf(from);
    segments[index + direction]?.focus();
  };

  const invalid = isOutOfRange(time, min, max);

  const segmentProps = (segment: "hour" | "minute" | "second") => {
    const max12 = segment === "hour" && resolvedHourCycle === 12;
    const bounds = {
      min: max12 ? 1 : 0,
      max: segment === "hour" ? (max12 ? 12 : 23) : 59,
    };

    const displayed = getDisplayValue(parts, segment, resolvedHourCycle);
    const increment = segment === "minute" ? step : 1;

    const setSegment = (nextDisplayed: number | null) => {
      commit(applySegment(parts, segment, nextDisplayed, resolvedHourCycle));
    };

    return {
      "data-segment": segment,
      role: "spinbutton" as const,
      tabIndex: disabled ? -1 : 0,
      "aria-label": segment,
      "aria-valuemin": bounds.min,
      "aria-valuemax": bounds.max,
      "aria-valuenow": displayed ?? undefined,
      "aria-valuetext": displayed === null ? "Empty" : pad(displayed),
      "aria-disabled": disabled || undefined,
      "aria-readonly": readOnly || undefined,
      "aria-invalid": invalid || undefined,
      onKeyDown: (event: React.KeyboardEvent<HTMLSpanElement>) => {
        if (disabled) return;

        if (event.key === "ArrowUp" || event.key === "ArrowDown") {
          event.preventDefault();
          if (readOnly) return;

          const delta = event.key === "ArrowUp" ? increment : -increment;
          const current = displayed ?? (delta > 0 ? bounds.min - 1 : bounds.max + 1);

          setSegment(wrap(current + delta, bounds.min, bounds.max));
          return;
        }

        if (event.key === "ArrowRight") {
          event.preventDefault();
          focusSibling(event.currentTarget, 1);
          return;
        }

        if (event.key === "ArrowLeft") {
          event.preventDefault();
          focusSibling(event.currentTarget, -1);
          return;
        }

        if (event.key === "Backspace" || event.key === "Delete") {
          event.preventDefault();
          if (readOnly) return;

          typing.current = null;
          setSegment(null);
          return;
        }

        if (!/^[0-9]$/u.test(event.key)) return;

        event.preventDefault();
        if (readOnly) return;

        const previous = typing.current;
        const fresh = !previous || previous.segment !== segment || Date.now() - previous.at > 1500;
        const digits = fresh ? event.key : `${previous.digits}${event.key}`;
        const candidate = Number(digits);

        if (candidate > bounds.max || digits.length > 2) {
          typing.current = { segment, digits: event.key, at: Date.now() };
          setSegment(clampToBounds(Number(event.key), bounds));
          return;
        }

        typing.current = { segment, digits, at: Date.now() };
        setSegment(clampToBounds(candidate, bounds));

        const canExtend = candidate * 10 <= bounds.max && digits.length < 2;

        if (!canExtend) {
          typing.current = null;
          focusSibling(event.currentTarget, 1);
        }
      },
    };
  };

  const dayPeriod = parts.hour === null ? null : parts.hour >= 12 ? "PM" : "AM";

  const setDayPeriod = (next: "AM" | "PM") => {
    if (parts.hour === null) return;

    const base = parts.hour % 12;
    commit({ ...parts, hour: next === "PM" ? base + 12 : base });
  };

  return (
    <div
      ref={containerRef}
      data-slot="time-picker"
      role="group"
      aria-label={ariaLabel}
      aria-disabled={disabled || undefined}
      data-invalid={invalid || undefined}
      className={cn(
        "inline-flex h-8 items-center gap-0.5 rounded-lg border border-input bg-transparent px-2 text-sm transition-colors",
        "focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50",
        "aria-disabled:pointer-events-none aria-disabled:opacity-50",
        "data-invalid:border-destructive data-invalid:focus-within:ring-destructive/20",
        className,
      )}
      {...props}
    >
      <IconClock className="mr-1 size-3.5 shrink-0 text-muted-foreground" />

      <TimeSegment
        {...segmentProps("hour")}
        value={getDisplayValue(parts, "hour", resolvedHourCycle)}
      />
      <span className="text-muted-foreground">:</span>
      <TimeSegment {...segmentProps("minute")} value={parts.minute} />

      {withSeconds ? (
        <>
          <span className="text-muted-foreground">:</span>
          <TimeSegment {...segmentProps("second")} value={parts.second} />
        </>
      ) : null}

      {resolvedHourCycle === 12 ? (
        <span
          data-segment="day-period"
          role="spinbutton"
          tabIndex={disabled ? -1 : 0}
          aria-label="AM or PM"
          aria-valuetext={dayPeriod ?? "Empty"}
          aria-disabled={disabled || undefined}
          className="ml-1 rounded px-1 py-0.5 tabular-nums outline-none select-none focus:bg-primary focus:text-primary-foreground data-empty:text-muted-foreground"
          data-empty={dayPeriod === null || undefined}
          onKeyDown={(event) => {
            if (disabled || readOnly) return;

            if (event.key === "ArrowUp" || event.key === "ArrowDown") {
              event.preventDefault();
              setDayPeriod(dayPeriod === "PM" ? "AM" : "PM");
              return;
            }

            if (event.key === "ArrowLeft") {
              event.preventDefault();
              focusSibling(event.currentTarget, -1);
              return;
            }

            const key = event.key.toLowerCase();

            if (key === "a" || key === "p") {
              event.preventDefault();
              setDayPeriod(key === "a" ? "AM" : "PM");
            }
          }}
        >
          {dayPeriod ?? "--"}
        </span>
      ) : null}

      {name ? <input type="hidden" name={name} value={time ?? ""} readOnly /> : null}
    </div>
  );
}

type TimeSegmentProps = React.ComponentProps<"span"> & {
  value: number | null;
};

function TimeSegment({ value, className, ...props }: TimeSegmentProps) {
  return (
    <span
      className={cn(
        "rounded px-1 py-0.5 tabular-nums outline-none select-none",
        "focus:bg-primary focus:text-primary-foreground",
        "data-empty:text-muted-foreground",
        className,
      )}
      data-empty={value === null || undefined}
      {...props}
    >
      {value === null ? "--" : pad(value)}
    </span>
  );
}

function parseTime(value: string | null): TimeParts {
  if (!value) return { hour: null, minute: null, second: null };

  const match = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/u.exec(value);
  if (!match) return { hour: null, minute: null, second: null };

  return {
    hour: Number(match[1]),
    minute: Number(match[2]),
    second: match[3] === undefined ? 0 : Number(match[3]),
  };
}

function formatTime(parts: TimeParts, withSeconds: boolean): string {
  const base = `${pad(parts.hour ?? 0)}:${pad(parts.minute ?? 0)}`;
  return withSeconds ? `${base}:${pad(parts.second ?? 0)}` : base;
}

function getDisplayValue(
  parts: TimeParts,
  segment: "hour" | "minute" | "second",
  hourCycle: TimePickerHourCycle,
): number | null {
  if (segment === "minute") return parts.minute;
  if (segment === "second") return parts.second;
  if (parts.hour === null) return null;
  if (hourCycle === 24) return parts.hour;

  return parts.hour % 12 === 0 ? 12 : parts.hour % 12;
}

function applySegment(
  parts: TimeParts,
  segment: "hour" | "minute" | "second",
  displayed: number | null,
  hourCycle: TimePickerHourCycle,
): TimeParts {
  if (segment === "minute") return { ...parts, minute: displayed };
  if (segment === "second") return { ...parts, second: displayed };
  if (displayed === null) return { ...parts, hour: null };
  if (hourCycle === 24) return { ...parts, hour: displayed };

  const wasAfternoon = (parts.hour ?? 0) >= 12;
  const base = displayed % 12;

  return { ...parts, hour: wasAfternoon ? base + 12 : base };
}

function isOutOfRange(value: string | null, min?: string, max?: string): boolean {
  if (!value) return false;
  if (min && value < min) return true;
  if (max && value > max) return true;

  return false;
}

function getLocaleHourCycle(): TimePickerHourCycle {
  if (typeof Intl === "undefined") return 24;

  const [hourPart] = new Intl.DateTimeFormat(undefined, { hour: "numeric" }).formatToParts(
    new Date(2020, 0, 1, 13),
  );

  return hourPart?.value === "1" ? 12 : 24;
}

function pad(value: number): string {
  return String(value).padStart(2, "0");
}

function wrap(value: number, min: number, max: number): number {
  const size = max - min + 1;
  return ((((value - min) % size) + size) % size) + min;
}

function clampToBounds(value: number, bounds: { min: number; max: number }): number {
  return Math.min(bounds.max, Math.max(bounds.min, value));
}

export { TimePicker, type TimePickerHourCycle, type TimePickerProps };
```



## Usage

Segmented hour, minute, and optional second fields that behave the way a native date field does —
type digits and the caret advances, arrows step, each segment announces itself — without inheriting
`<input type="time">`'s unstyleable UI or its inconsistent behavior across browsers.

```tsx
import { TimePicker } from "@/components/ui/time-picker";

export function ReminderField() {
  const [time, setTime] = React.useState<string | null>("09:30");

  return <TimePicker value={time} onValueChange={setTime} />;
}
```

The value is always 24-hour `"HH:mm"` regardless of what is displayed, and `null` until every
segment is filled. That is the form a `time` column, an ISO timestamp, and a `<input type="hidden">`
all want.

## Hour cycle

Display follows the viewer's locale by default — a US visitor sees `09:30 AM`, a French visitor sees
`09:30` — while the value stays 24-hour. Force it when the domain demands one format:

```tsx
<TimePicker hourCycle={24} withSeconds />
```

The locale is only readable on the client, so a server render cannot know it. Left unset, the field
renders 24-hour on the server and switches to the locale's cycle once mounted — a 12-hour visitor
sees the AM/PM segment appear on hydration. Pass `hourCycle` explicitly to render the same markup on
both sides and avoid that shift.

## Stepping

`step` is the minute increment for the arrow keys. Typing digits is never restricted by it, so a
15-minute grid still permits an exact `09:07` when someone types it.

```tsx
<TimePicker step={15} />
```

## Range

`min` and `max` mark the field invalid — `data-invalid` for styling, `aria-invalid` on the segments —
rather than silently rewriting what the user typed. Clamping input as it is typed makes intermediate
values impossible to reach.

```tsx
<TimePicker min="09:00" max="17:00" />
```

## Props

| Prop            | Description                                                            |
| --------------- | ---------------------------------------------------------------------- |
| `value` / `defaultValue` / `onValueChange` | Controlled or uncontrolled `"HH:mm"` string, or `null`. |
| `hourCycle`     | `12` or `24`. Defaults to the viewer's locale, resolved after mount.     |
| `step`          | Minute increment for the arrow keys. Defaults to `1`.                   |
| `withSeconds`   | Add a seconds segment; the value becomes `"HH:mm:ss"`.                  |
| `min` / `max`   | Flag values outside the range as invalid.                               |
| `readOnly`      | Focusable and announced, but not editable.                              |
| `name`          | Emits a hidden input so the field posts in a plain HTML form.           |

## Keyboard

| Key                | Action                                                            |
| ------------------ | ----------------------------------------------------------------- |
| `↑` / `↓`          | Step the focused segment, wrapping at its bounds.                  |
| `←` / `→`          | Move between segments.                                             |
| `0`–`9`            | Type directly. The caret advances as soon as no further digit fits — `3` in a 24-hour hour segment moves on immediately, `1` waits for a second digit. |
| `Backspace` / `Delete` | Clear the segment.                                             |
| `A` / `P`          | Set AM or PM on the day-period segment.                            |

Each segment is a `spinbutton` with its own label and value text, so a screen reader announces
"hour, 9" rather than reading the whole field as one opaque string. Empty segments read as "Empty"
instead of "minus minus".

## Composing a range

Two pickers and a [date range picker](/components/date-range-picker) make a datetime range without
another component:

```tsx
<DateRangePicker value={range} onValueChange={setRange} />
<TimePicker value={startTime} onValueChange={setStartTime} aria-label="Start time" />
<TimePicker value={endTime} onValueChange={setEndTime} aria-label="End time" />
```

