Reach for <input type="time"> first. It is one line, it needs no JavaScript, and it
inherits the platform's own time entry, including the native wheel on iOS and Android. Use
this component when you need a fixed set of slots instead of free entry: booking windows,
opening hours, or anything where 09:07 is not a valid answer.
The value is always a 24-hour "HH:mm" string, so what you store never depends on where the user is. Only the display is localised, through Intl.DateTimeFormat.
hour12 follows what the locale itself prefers, not AM/PM, so an en-GB or de-DE user is not shown a format they do not use. Pass hour12 to override it in either direction.
Without locale, the picker formats with navigator.language rather than Intl's own default. Chrome derives that default from the browser's UI language, which on macOS routinely disagrees with the language the user asked pages to be in: a browser reporting en-US still resolves Intl.DateTimeFormat() to en-GB, and would show 24-hour times to someone who has only ever asked for American English.
The probe runs after hydration, so the server renders the raw 24-hour string and the two can never disagree. Pass locale when you know it up front and want the formatted time in the first paint.
size="sm" drops the trigger to 40px and the text to text-sm, matching an icon-sized button. Use it where a time sits in a dense row rather than in a column of full-height fields.
A value that does not sit on the step grid, say a time saved before step changed, is added to the list instead of dropped. The control never silently discards what it was given.
Range
Constrain the end of a range with min, and move it along when the start passes it.
Loading...
"use client";import { useId, useState } from "react";import { Field, FieldDescription, FieldLabel } from "@/components/ui/field";import { TimePicker } from "@/components/ui/time-picker";const STEP = 30;/** Adds `STEP` minutes to an `"HH:mm"` string, clamped to the end of the day. */const nextSlot = (time: string) => { const [hours, minutes] = time.split(":").map(Number); const total = Math.min(23 * 60 + 30, hours * 60 + minutes + STEP); return `${String(Math.floor(total / 60)).padStart(2, "0")}:${String(total % 60).padStart(2, "0")}`;};export const TimePickerRange = () => { const startId = useId(); const endId = useId(); const [start, setStart] = useState("09:00"); const [end, setEnd] = useState("17:00"); const handleStartChange = (value: string) => { setStart(value); if (end <= value) { setEnd(nextSlot(value)); } }; return ( <Field className="w-72"> <FieldLabel htmlFor={startId}>Availability</FieldLabel> <div className="flex items-center gap-2"> <TimePicker id={startId} locale="en-US" onValueChange={handleStartChange} step={STEP} value={start} /> <span className="text-muted-foreground text-sm">to</span> <TimePicker id={endId} locale="en-US" min={nextSlot(start)} onValueChange={setEnd} step={STEP} value={end} /> </div> <FieldDescription>Moving the start time pushes the end time along with it.</FieldDescription> </Field> );};
Forcing a 24-hour clock
Loading...
"use client";import { useId, useState } from "react";import { Field, FieldDescription, FieldLabel } from "@/components/ui/field";import { TimePicker } from "@/components/ui/time-picker";export const TimePicker24Hour = () => { const id = useId(); const [time, setTime] = useState("18:45"); return ( <Field className="w-48"> <FieldLabel htmlFor={id}>Departure</FieldLabel> <TimePicker hour12={false} id={id} locale="en-GB" onValueChange={setTime} value={time} /> <FieldDescription>Stored as 18:45 either way.</FieldDescription> </Field> );};