DateTimePicker

A date and a time in one control.

Usage

import { DateTimePicker } from "@voila.dev/ui/date-time-picker";

One field for a moment: a calendar sets the day, a scrollable list sets the time, and the trigger shows both. Use it when the two halves belong to one answer — when a meeting starts, when a job is due. When they are genuinely separate questions, two fields read better: a DatePicker and a TimePicker side by side.

The value is a single Date, in the viewer's local clock. Picking a day before any time exists fills the time in at 09:00 rather than handing you a date at midnight — midnight is almost never what someone meant by "Tuesday".

Recipes

A start and an end

DateTimePicker.Range is two bound fields sharing one { start, end } value. Picking a start seeds the end an hour later, and pushes it forward if a new start would overtake it, so the range cannot go backwards without you writing that rule yourself.

A shift that crosses midnight

DateTimePicker.ShiftRange is one trigger with a Start/End tab inside. Because each end carries its own date, 20:00 → 02:00 the next day is picked directly — no "add a day if the end is smaller" trick, which is the bug every hand-rolled shift field eventually ships.

DateTimePicker.Responsive switches surfaces at the mobile breakpoint: the popover on desktop, the OS picker below it. DateTimePicker.Native is that OS picker on its own, with the same Date | null value model as the rest, so the choice of surface never changes your state.

API

DateTimePicker.Root

PropTypeDefault
aria-invalid"false" | "grammar" | "spelling" | "true" | booleanMarks the field invalid. Pair it with your own message — this draws no text.
aria-labelstringAccessible name, for when there is no visible label to point id at.
calendarPropsOmit<Props, "locale" | "mode" | "onSelect" | "selected">Escape hatch for the underlying Calendar (disabled days, week numbers…).
classNamestringClasses for the field. Both surfaces fill their container by default.
defaultOpenbooleanWhether the popover starts open. Uncontrolled — for the controlled form use open.
defaultValueDateInitial value when uncontrolled.
disabledbooleanBlocks the field, so neither surface can be opened.
formatOptionsDateTimeFormatOptionsDEFAULT_DATE_TIME_FORMATIntl.DateTimeFormat options for the trigger label. Defaults to { dateStyle: "medium", timeStyle: "short" } (e.g. "Jun 20, 2026, 2:30 PM").
idstringTies the field to a <label>. Pass it when there is a visible label.
localestringBCP-47 locale (e.g. "fr-FR"), applied to the trigger label, calendar, and time labels.
minuteStepnumber30Minutes between two options in the time list. Defaults to 30.
namestringName for a hidden form input; the value is serialized as yyyy-MM-ddTHH:mm.
onOpenChange((open: boolean) => void)Called when the popover opens or closes.
onValueChange((date: Date | null) => void)Called with the combined datetime, or null when cleared. Picking a day before any time exists fills the time in at 09:00 rather than emitting a date at midnight.
openbooleanControlled popover state. Pair it with onOpenChange.
placeholderstring"Pick a date and time"Shown on the trigger while nothing is selected. Name the field.
valueDate | nullControlled value; pass null for a controlled empty selection.
variant"brand" | "default" | "destructive" | "ghost" | "highlight" | "link" | "outline" | "primary" | "secondary"Button variant for the trigger. outline is the field-shaped default.

Plus the DOM props of the element it renders. Source: date-time-picker/components/date-time-picker-root.tsx.

DateTimePicker.Native

PropTypeDefault
onValueChange((date: Date | null) => void)Called with the parsed Date, or null when the input is emptied.
size"default" | "sm"Field height. sm for dense surfaces such as table cells and toolbars.
valueDate | nullControlled value; pass null for a controlled empty selection.
wrapperClassNamestringClasses for the wrapper <div> that hosts the leading icon. Use this for layout and width (e.g. w-full, w-48); className styles the inner <input> itself, matching the rest of the kit's form controls.

Plus the DOM props of the element it renders. Source: date-time-picker/components/native-date-time-input.tsx.

DateTimePicker.Range

PropTypeDefault
aria-invalid"false" | "grammar" | "spelling" | "true" | booleanMarks both fields invalid. Pair it with your own message.
classNamestringStyles the wrapping grid (e.g. md:col-span-2).
defaultDurationMinutesnumber60Duration (minutes) used to seed the end when a start is picked while the end is still empty, and to push the end forward when a new start lands on or past it, so the range stays valid without extra clicks. Defaults to 60.
defaultValueDateTimeRangeInitial range when uncontrolled.
disabledbooleanBlocks both fields.
endIdstringid for the end field. Derived from startId when omitted.
endLabelReactNode"End"Label above the end field. Defaults to "End".
endPlaceholderstringPlaceholder for the end field while it is empty.
formatOptionsDateTimeFormatOptionsIntl.DateTimeFormat options for the desktop trigger labels.
localestringBCP-47 locale (e.g. "fr-FR"), applied to both fields' labels, calendars, and time lists.
minuteStepnumberMinutes between two options in each time list. Defaults to 30.
onValueChange((range: DateTimeRange) => void)Called with the whole { start, end } after either side changes.
startIdstringid for the start field; the end field derives ${startId}-end when endId is omitted.
startLabelReactNode"Start"Label above the start field. Defaults to "Start".
startPlaceholderstringPlaceholder for the start field while it is empty.
valueDateTimeRangeControlled range; both sides may be null independently.

Plus the DOM props of the element it renders. Source: date-time-picker/components/date-time-range-input.tsx.

DateTimePicker.Responsive

PropTypeDefault
aria-invalid"false" | "grammar" | "spelling" | "true" | booleanMarks the field invalid. Pair it with your own message — this draws no text.
aria-labelstringAccessible name, for when there is no visible label to point id at.
calendarPropsOmit<Props, "locale" | "mode" | "onSelect" | "selected">Escape hatch for the desktop Calendar. Ignored on the native surface.
classNamestringClasses for the field. Both surfaces fill their container by default.
defaultValueDateInitial value when uncontrolled.
disabledbooleanBlocks the field, so neither surface can be opened.
formatOptionsDateTimeFormatOptionsIntl.DateTimeFormat options for the desktop trigger label.
idstringTies the field to a <label>. Pass it when there is a visible label.
localestringBCP-47 locale (e.g. "fr-FR"), applied to the trigger label, calendar, and time labels.
maxstringMax selectable time on the native input, HH:mm.
minstringMin selectable time on the native input, HH:mm.
minuteStepnumberMinutes between two options in the time list. Defaults to 30.
namestringName for a hidden form input; the value is serialized as yyyy-MM-ddTHH:mm.
onValueChange((date: Date | null) => void)Called with the combined datetime, or null when cleared. Picking a day before any time exists fills the time in at 09:00 rather than emitting a date at midnight.
placeholderstringShown on the trigger while nothing is selected. Name the field.
valueDate | nullControlled value; pass null for a controlled empty selection.

Plus the DOM props of the element it renders. Source: date-time-picker/components/responsive-date-time-input.tsx.

DateTimePicker.ShiftRange

PropTypeDefault
aria-invalid"false" | "grammar" | "spelling" | "true" | booleanMarks the trigger invalid. Pair it with your own message.
aria-labelstringAccessible name, for when there is no visible label.
classNamestringClasses for the trigger.
defaultDurationMinutesnumber60Duration (minutes) seeding the end when only a start exists. Defaults to 60.
defaultValueDateTimeRangeInitial range when uncontrolled.
disabledbooleanfalseBlocks the trigger, so the popover cannot be opened.
idstringTies the trigger to a <label>.
localestringBCP-47 locale (e.g. "fr-FR") for the trigger label, calendar, and time lists.
minuteStepnumber30Minutes between two options in each time list. Defaults to 30.
onValueChange((range: DateTimeRange) => void)Called with the whole { start, end } after either side changes.
placeholderstring"Pick a shift"Shown on the single trigger while the shift is empty.
valueDateTimeRangeControlled range; both sides may be null independently.

Plus the DOM props of the element it renders. Source: date-time-picker/components/shift-time-range-input.tsx.

DatePicker · TimePicker · NativeDatePicker · Calendar