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
| Prop | Type | Default | |
|---|---|---|---|
aria-invalid | "false" | "grammar" | "spelling" | "true" | boolean | — | Marks the field invalid. Pair it with your own message — this draws no text. |
aria-label | string | — | Accessible name, for when there is no visible label to point id at. |
calendarProps | Omit<Props, "locale" | "mode" | "onSelect" | "selected"> | — | Escape hatch for the underlying Calendar (disabled days, week numbers…). |
className | string | — | Classes for the field. Both surfaces fill their container by default. |
defaultOpen | boolean | — | Whether the popover starts open. Uncontrolled — for the controlled form use open. |
defaultValue | Date | — | Initial value when uncontrolled. |
disabled | boolean | — | Blocks the field, so neither surface can be opened. |
formatOptions | DateTimeFormatOptions | DEFAULT_DATE_TIME_FORMAT | Intl.DateTimeFormat options for the trigger label. Defaults to
{ dateStyle: "medium", timeStyle: "short" } (e.g. "Jun 20, 2026, 2:30 PM"). |
id | string | — | Ties the field to a <label>. Pass it when there is a visible label. |
locale | string | — | BCP-47 locale (e.g. "fr-FR"), applied to the trigger label, calendar, and time labels. |
minuteStep | number | 30 | Minutes between two options in the time list. Defaults to 30. |
name | string | — | Name 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. |
open | boolean | — | Controlled popover state. Pair it with onOpenChange. |
placeholder | string | "Pick a date and time" | Shown on the trigger while nothing is selected. Name the field. |
value | Date | null | — | Controlled 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
| Prop | Type | Default | |
|---|---|---|---|
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. |
value | Date | null | — | Controlled value; pass null for a controlled empty selection. |
wrapperClassName | string | — | Classes 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
| Prop | Type | Default | |
|---|---|---|---|
aria-invalid | "false" | "grammar" | "spelling" | "true" | boolean | — | Marks both fields invalid. Pair it with your own message. |
className | string | — | Styles the wrapping grid (e.g. md:col-span-2). |
defaultDurationMinutes | number | 60 | Duration (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. |
defaultValue | DateTimeRange | — | Initial range when uncontrolled. |
disabled | boolean | — | Blocks both fields. |
endId | string | — | id for the end field. Derived from startId when omitted. |
endLabel | ReactNode | "End" | Label above the end field. Defaults to "End". |
endPlaceholder | string | — | Placeholder for the end field while it is empty. |
formatOptions | DateTimeFormatOptions | — | Intl.DateTimeFormat options for the desktop trigger labels. |
locale | string | — | BCP-47 locale (e.g. "fr-FR"), applied to both fields' labels, calendars, and time lists. |
minuteStep | number | — | Minutes between two options in each time list. Defaults to 30. |
onValueChange | ((range: DateTimeRange) => void) | — | Called with the whole { start, end } after either side changes. |
startId | string | — | id for the start field; the end field derives ${startId}-end when endId is omitted. |
startLabel | ReactNode | "Start" | Label above the start field. Defaults to "Start". |
startPlaceholder | string | — | Placeholder for the start field while it is empty. |
value | DateTimeRange | — | Controlled 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
| Prop | Type | Default | |
|---|---|---|---|
aria-invalid | "false" | "grammar" | "spelling" | "true" | boolean | — | Marks the field invalid. Pair it with your own message — this draws no text. |
aria-label | string | — | Accessible name, for when there is no visible label to point id at. |
calendarProps | Omit<Props, "locale" | "mode" | "onSelect" | "selected"> | — | Escape hatch for the desktop Calendar. Ignored on the native surface. |
className | string | — | Classes for the field. Both surfaces fill their container by default. |
defaultValue | Date | — | Initial value when uncontrolled. |
disabled | boolean | — | Blocks the field, so neither surface can be opened. |
formatOptions | DateTimeFormatOptions | — | Intl.DateTimeFormat options for the desktop trigger label. |
id | string | — | Ties the field to a <label>. Pass it when there is a visible label. |
locale | string | — | BCP-47 locale (e.g. "fr-FR"), applied to the trigger label, calendar, and time labels. |
max | string | — | Max selectable time on the native input, HH:mm. |
min | string | — | Min selectable time on the native input, HH:mm. |
minuteStep | number | — | Minutes between two options in the time list. Defaults to 30. |
name | string | — | Name 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. |
placeholder | string | — | Shown on the trigger while nothing is selected. Name the field. |
value | Date | null | — | Controlled 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
| Prop | Type | Default | |
|---|---|---|---|
aria-invalid | "false" | "grammar" | "spelling" | "true" | boolean | — | Marks the trigger invalid. Pair it with your own message. |
aria-label | string | — | Accessible name, for when there is no visible label. |
className | string | — | Classes for the trigger. |
defaultDurationMinutes | number | 60 | Duration (minutes) seeding the end when only a start exists. Defaults to 60. |
defaultValue | DateTimeRange | — | Initial range when uncontrolled. |
disabled | boolean | false | Blocks the trigger, so the popover cannot be opened. |
id | string | — | Ties the trigger to a <label>. |
locale | string | — | BCP-47 locale (e.g. "fr-FR") for the trigger label, calendar, and time lists. |
minuteStep | number | 30 | Minutes between two options in each time list. Defaults to 30. |
onValueChange | ((range: DateTimeRange) => void) | — | Called with the whole { start, end } after either side changes. |
placeholder | string | "Pick a shift" | Shown on the single trigger while the shift is empty. |
value | DateTimeRange | — | Controlled 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.