- Accordion
- AppShell
- Autocomplete
- Badge
- Blobatar
- BoringAvatar
- Button
- Calendar
- Checkbox
- DataGrid
- Date Picker
- Date Range Picker
- DateTime
- Date Time Picker
- Date Time Range Picker
- Dialog
- Drawer
- Dropdown
- Empty
- Error State
- Frame
- Input
- Input Amount
- Input Group
- Input OTP
- Input Password
- Input Phone
- Input Search
- Input Secret
- Loading
- PageContainer
- PageHeader
- Popover
- Select
- StatsCard
- Switch
- Tabs
- TextArea
- Toast
- Tooltip
Playground
"use client";
import { useState } from "react";
import type { DateRange } from "react-day-picker";
import { DateTimeRangePicker } from "@/components/ui/date-time-range-picker";
export function DateTimeRangePickerDemo() {
const [range, setRange] = useState<DateRange>();
return (
<>
<DateTimeRangePicker
label={"Maintenance window"}
value={range}
onValueChange={setRange}
placeholder={"Select date and time range"}
dateFormat={"PPP p"}
precision={"minute"}
hourCycle={12}
motion={true}
/>
</>
);
}Installation
Initialize shadcn in your React project, then install this component from the Vandor UI registry:
$ pnpm dlx shadcn@latest add https://vandor-ui.vercel.app/r/date-time-range-picker.json
Dependencies
Installed automatically by the registry CLI. For manual setup:
- cn@^0.4.0
- date-fns@^4.4.0
- lucide-react@1.11.0
- react-day-picker@^10.0.2
$ pnpm add cn@^0.4.0 date-fns@^4.4.0 lucide-react@1.11.0 react-day-picker@^10.0.2Copying the Source block alone is insufficient. Use the CLI installation above to include all registry dependencies and supporting files; the npm command alone does not install them.
Required registry dependencies: https://vandor-ui.vercel.app/r/button.json, https://vandor-ui.vercel.app/r/calendar.json, https://vandor-ui.vercel.app/r/date-time-picker.json, https://vandor-ui.vercel.app/r/popover.json.
Required source and supporting files: registry/new-york/date-time-range-picker.tsx.
Usage
import { DateTimeRangePicker } from "@/components/ui/date-time-range-picker";
<DateTimeRangePicker
label="Maintenance window"
value={range}
onValueChange={setRange}
/>;The value is a DateRange whose from and to are JavaScript Dates holding a local day and a local time of day. Pick days in the calendar, set a start and an end time, then press Apply. Set clearable to add a Clear action that commits an empty value immediately.
This builds on Date Time Picker and shares its time field and precision rules. It differs from Date Range Picker, which selects whole days. motion={false} disables popup, Calendar, and button animations; system reduced-motion preferences take priority.
Consumer setup
Requires React 19.2+, Tailwind CSS 4 with shadcn theme tokens, and the declared dependencies. CLI installation includes Date Time Picker, Button (and its Loading files), Calendar, and Popover; review existing primitive overwrite prompts. Copying Source alone is insufficient for manual installation: also install the registry dependencies.
Storybook stories (optional)
Install DateTimeRangePicker first, then:
$ pnpm dlx shadcn@latest add https://vandor-ui.vercel.app/r/date-time-range-picker-stories.json
Only the stories file is installed; no component or Storybook configuration is overwritten. Add your UI directory to your existing **/*.stories.@(js|jsx|mjs|ts|tsx) discovery pattern and import your app's global Tailwind CSS in Storybook preview. Stories use @storybook/react 10.6.1 types/composition; keep Storybook packages aligned. autodocs needs your existing docs addon. Stories inherit consumer themes.
Examples
A controlled picker with a clear action. Start defaults to the beginning of the first day and end to the last minute of the last day.
"use client";
import { useState } from "react";
import type { DateRange } from "react-day-picker";
import { DateTimeRangePicker } from "@/components/ui/date-time-range-picker";
export const DateTimeRangePickerDemo = () => {
const [range, setRange] = useState<DateRange>();
return (
<DateTimeRangePicker
label="Maintenance window"
value={range}
onValueChange={setRange}
clearable
/>
);
};A same-day time window with a 24-hour cycle (hourCycle={24}). One clicked day is a complete range, so no second click is needed.
"use client";
import { useState } from "react";
import type { DateRange } from "react-day-picker";
import { DateTimeRangePicker } from "@/components/ui/date-time-range-picker";
export const DateTimeRangePicker24hDemo = () => {
const [range, setRange] = useState<DateRange | undefined>({
from: new Date(2026, 9, 5, 9, 0),
to: new Date(2026, 9, 5, 17, 30),
});
return (
<DateTimeRangePicker
label="Opening hours"
value={range}
onValueChange={setRange}
dateFormat="PPP HH:mm"
hourCycle={24}
/>
);
};Behavior & Accessibility
Picking days or either time changes a draft only. onValueChange fires on Apply with a complete { from, to }, and on Clear with undefined. Cancel, Escape, or an outside click discards the draft; reopening always starts from the committed value. Enter inside a time column applies.
A single clicked day is a same-day range, so one day with a start and an end time is valid. Apply is disabled until days are chosen, and while the end is before the start, including on the same day. In that case a message (rangeErrorLabel) appears in a polite live region, every time column gets aria-invalid and references the message through aria-describedby.
Emitted dates always have zero milliseconds, and zero seconds at the default minute precision. With precision="second" an empty end time starts at 23:59:59. disabledDates makes any range that would include a disabled day reset; the month bounds limit navigation. Time is not constrained.
disabled prevents opening and closes an open popup. readOnly shows the range and lets the popup open, but the calendar, both time pickers, Apply, and Clear cannot change anything. value (including explicit undefined) is controlled; otherwise defaultValue initializes internal state. open/onOpenChange independently control visibility.
The trigger's accessible name includes the label and the displayed range. The popup has an accessible title, and each time picker is a labelled group (startTimeLabel, endTimeLabel) of listboxes named by timeUnitLabels, with the same keyboard behavior as Date Time Picker. Escape returns focus to the trigger.
Time zone and formatting
Dates and times are read and written in the device's local time zone. Convert explicitly before sending a value to a server. A local time that does not exist because of a daylight-saving gap resolves to the next valid local time.
The time columns use a 12-hour or 24-hour cycle: the locale's time pattern decides by default, and hourCycle overrides it. The trigger text follows dateFormat (default "PPP p"), so pair hourCycle={24} with a 24-hour format such as "PPP HH:mm". Shortcuts and a two-month layout are not offered; use Date Range Picker when you only need whole days.
This is not a native form input: serialize your range explicitly. The public API is restricted to the listed props, not arbitrary native input attributes.
Props
| Prop | Type / Description | Default |
|---|---|---|
| motion | booleanEnables popup, calendar, and button motion; system reduced-motion takes priority. | true |
| applyLabel | stringDateTimeRangePicker: apply button text. | "Apply" |
| cancelLabel | stringDateTimeRangePicker: cancel button text. | "Cancel" |
| className | stringDateTimeRangePicker: trigger class name. | undefined |
| clearLabel | stringDateTimeRangePicker: clear button text. | "Clear" |
| clearable | booleanDateTimeRangePicker: shows a Clear action that commits an empty value immediately. | false |
| dateFormat | stringDateTimeRangePicker: date-fns display format for the trigger. Use a 24-hour token such as HH:mm together with hourCycle 24 for a 24-hour display. | "PPP p" |
| defaultOpen | booleanDateTimeRangePicker: initial uncontrolled popover visibility. | false |
| defaultValue | DateRange | undefinedDateTimeRangePicker: initial uncontrolled range with local start and end times. | undefined |
| disabled | booleanDateTimeRangePicker: disables opening the trigger and closes an open popup. | false |
| disabledDates | Matcher | Matcher[]DateTimeRangePicker: calendar days that cannot be part of a range. Time is not constrained. | undefined |
| endMonth | DateDateTimeRangePicker: latest navigable month. | undefined |
| hourCycle | 12 | 24DateTimeRangePicker: hour cycle of the time columns. By default the locale's time pattern decides (12-hour without a locale); set it so the columns agree with dateFormat. | from locale |
| timeUnitLabels | { hours: string; minutes: string; seconds: string; period: string }DateTimeRangePicker: accessible names of the hour, minute, second and AM/PM columns. | English labels |
| id | stringDateTimeRangePicker: optional stable id for the label relationship. | generated |
| label | stringDateTimeRangePicker: visible accessible name. | required |
| locale | date-fns LocaleDateTimeRangePicker: display and calendar locale. | undefined |
| onOpenChange | (open: boolean) => voidDateTimeRangePicker: observes popover visibility changes. | undefined |
| onValueChange | (range: DateRange | undefined) => voidDateTimeRangePicker: fires on Apply with a complete { from, to }, or on Clear with undefined. Never fires while editing the draft. | undefined |
| open | booleanDateTimeRangePicker: controlled popover visibility. | uncontrolled |
| placeholder | stringDateTimeRangePicker: trigger text when empty. | "Select date and time range" |
| precision | "minute" | "second"DateTimeRangePicker: time granularity. Emitted dates always have zero milliseconds, and zero seconds at minute precision. | "minute" |
| readOnly | booleanDateTimeRangePicker: opens the popup but prevents changes. | false |
| startMonth | DateDateTimeRangePicker: earliest navigable month. | undefined |
| startTimeLabel | stringDateTimeRangePicker: start time field label. | "Start time" |
| endTimeLabel | stringDateTimeRangePicker: end time field label. | "End time" |
| rangeErrorLabel | stringDateTimeRangePicker: message shown, and linked to both time fields, when the end is before the start. | "The end must not be before the start." |
| value | DateRange | undefinedDateTimeRangePicker: controlled range with local start and end times. | uncontrolled |
DateTimeRangePicker uses a restricted props API and does not forward arbitrary native attributes, refs or event handlers. id labels the trigger; className styles the trigger; startTimeLabel and endTimeLabel name the time fields. Native form name, required and input submission are not supported.
Source
"use client";
import { cn } from "cn";
import { format } from "date-fns";
import type { Locale } from "date-fns";
import { CalendarClock } from "lucide-react";
import * as React from "react";
import type { DateRange, Matcher } from "react-day-picker";
import { Button } from "./button";
import { Calendar } from "./calendar";
import { PickerFooter, TimeField } from "./date-time-picker-parts";
import type { TimeUnitLabels } from "./date-time-picker-parts";
import {
defaultTimeValue,
formatTimeValue,
normalizeDateTime,
resolveDateTimeRangeDraft,
resolveHourCycle,
} from "./date-time-picker-utils";
import type {
DateTimePrecision,
DateTimeRangeDraft,
HourCycle,
} from "./date-time-picker-utils";
import {
Popover,
PopoverContent,
PopoverTitle,
PopoverTrigger,
} from "./popover";
// Composes Vandor's Button, Popover and Calendar with two time pickers. Values are
// local-time `Date`s; the draft is committed only by Apply (or Clear).
export interface DateTimeRangePickerProps {
id?: string;
label: string;
placeholder?: string;
clearable?: boolean;
value?: DateRange;
defaultValue?: DateRange;
onValueChange?: (range: DateRange | undefined) => void;
locale?: Locale;
dateFormat?: string;
precision?: DateTimePrecision;
hourCycle?: HourCycle;
timeUnitLabels?: TimeUnitLabels;
disabled?: boolean;
readOnly?: boolean;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
disabledDates?: Matcher | Matcher[];
startMonth?: Date;
endMonth?: Date;
startTimeLabel?: string;
endTimeLabel?: string;
rangeErrorLabel?: string;
applyLabel?: string;
cancelLabel?: string;
clearLabel?: string;
className?: string;
motion?: boolean;
}
interface Draft {
range: DateRange | undefined;
startTime: string;
endTime: string;
}
const draftFrom = (
committed: DateRange | undefined,
precision: DateTimePrecision
): Draft => ({
endTime: committed?.to
? formatTimeValue(committed.to, precision)
: defaultTimeValue("end", precision),
range: committed,
startTime: committed?.from
? formatTimeValue(committed.from, precision)
: defaultTimeValue("start", precision),
});
const withPickerDefaults = ({
placeholder = "Select date and time range",
clearable = false,
dateFormat = "PPP p",
precision = "minute",
defaultOpen = false,
startTimeLabel = "Start time",
endTimeLabel = "End time",
rangeErrorLabel = "The end must not be before the start.",
applyLabel = "Apply",
cancelLabel = "Cancel",
clearLabel = "Clear",
motion = true,
...props
}: DateTimeRangePickerProps) => ({
...props,
applyLabel,
cancelLabel,
clearLabel,
clearable,
dateFormat,
defaultOpen,
endTimeLabel,
motion,
placeholder,
precision,
rangeErrorLabel,
startTimeLabel,
});
const getShownValue = (
committed: DateRange | undefined,
precision: DateTimePrecision,
dateFormat: string,
locale: Locale | undefined,
placeholder: string
) => {
if (!committed?.from) {
return placeholder;
}
const show = (date: Date) =>
format(normalizeDateTime(date, precision), dateFormat, { locale });
return committed.to
? `${show(committed.from)} – ${show(committed.to)}`
: show(committed.from);
};
const toCommittableRange = (
resolved: DateTimeRangeDraft
): DateRange | undefined =>
resolved.start && resolved.end && !resolved.orderInvalid
? { from: resolved.start, to: resolved.end }
: undefined;
// The draft mirrors the committed value whenever the popup is closed, so a reopen
// (including a controlled `open`) never shows a discarded or stale edit.
const useRangeDraft = (
committed: DateRange | undefined,
precision: DateTimePrecision,
isOpen: boolean
) => {
const [draft, setDraft] = React.useState(() =>
draftFrom(committed, precision)
);
const resetDraft = React.useEffectEvent(() =>
setDraft(draftFrom(committed, precision))
);
const fromTime = committed?.from?.getTime();
const toTime = committed?.to?.getTime();
React.useEffect(() => {
if (!isOpen) {
resetDraft();
}
}, [isOpen, fromTime, toTime, precision]);
return [draft, setDraft] as const;
};
export const DateTimeRangePicker = (props: DateTimeRangePickerProps) => {
const {
id,
label,
placeholder,
clearable,
value,
defaultValue,
onValueChange,
locale,
dateFormat,
precision,
hourCycle,
timeUnitLabels,
disabled,
readOnly,
open,
defaultOpen,
onOpenChange,
disabledDates,
startMonth,
endMonth,
startTimeLabel,
endTimeLabel,
rangeErrorLabel,
applyLabel,
cancelLabel,
clearLabel,
className,
motion,
} = withPickerDefaults(props);
const blocked = Boolean(disabled || readOnly);
const controlled = Object.hasOwn(props, "value");
const generatedId = React.useId();
const triggerId = id ?? generatedId;
const [internal, setInternal] = React.useState(defaultValue);
const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
const committed = controlled ? value : internal;
const isOpen = !disabled && (open === undefined ? internalOpen : open);
React.useEffect(() => {
if (disabled) {
setInternalOpen(false);
}
}, [disabled]);
const [draft, setDraft] = useRangeDraft(committed, precision, isOpen);
const setOpen = (next: boolean) => {
if (disabled && next) {
return;
}
if (open === undefined) {
setInternalOpen(next);
}
onOpenChange?.(next);
};
const commit = (next?: DateRange) => {
if (blocked) {
return;
}
if (!controlled) {
setInternal(next);
}
onValueChange?.(next);
setOpen(false);
};
const resolved = resolveDateTimeRangeDraft(
draft.range,
draft.startTime,
draft.endTime,
precision
);
const next = blocked ? undefined : toCommittableRange(resolved);
const canApply = next !== undefined;
const apply = () => {
if (next) {
commit(next);
}
};
const cycle = resolveHourCycle(locale, hourCycle);
const errorId = `${triggerId}-range-error`;
const describedBy = resolved.orderInvalid ? errorId : undefined;
const whileTap = motion ? { scale: 0.96 } : false;
const shown = getShownValue(
committed,
precision,
dateFormat,
locale,
placeholder
);
return (
<div data-slot="date-time-range-picker" className="grid min-w-0 gap-1.5">
<label
htmlFor={triggerId}
id={`${triggerId}-label`}
className="text-sm font-medium"
>
{label}
</label>
<Popover open={isOpen} onOpenChange={setOpen}>
<PopoverTrigger
id={triggerId}
disabled={disabled}
aria-labelledby={`${triggerId}-label ${triggerId}-value`}
render={<Button variant="outline" whileTap={whileTap} />}
className={cn(
"inline-flex h-9 min-w-0 items-center justify-start gap-2 rounded-md border border-input bg-background px-3 text-sm font-normal shadow-xs outline-none hover:bg-accent focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 dark:bg-input/30",
className
)}
>
<CalendarClock aria-hidden="true" className="size-4 shrink-0" />
<span
id={`${triggerId}-value`}
className={cn(
"truncate",
!committed?.from && "text-muted-foreground"
)}
>
{shown}
</span>
</PopoverTrigger>
<PopoverContent
align="start"
animated={motion}
className="w-auto gap-0 p-0"
>
<PopoverTitle className="sr-only">{label}</PopoverTitle>
<Calendar
mode="range"
motion={motion}
autoFocus
excludeDisabled
defaultMonth={draft.range?.from}
locale={locale}
selected={draft.range}
disabled={blocked ? () => true : disabledDates}
startMonth={startMonth}
endMonth={endMonth}
onSelect={(range) => {
if (!blocked) {
setDraft((current) => ({ ...current, range }));
}
}}
/>
<div className="grid gap-3 border-t p-3">
<div className="grid gap-3 sm:grid-cols-[max-content_max-content]">
<TimeField
id={`${triggerId}-start-time`}
label={startTimeLabel}
value={draft.startTime}
precision={precision}
hourCycle={cycle}
locale={locale}
unitLabels={timeUnitLabels}
disabled={disabled}
readOnly={readOnly}
invalid={resolved.startTimeInvalid || resolved.orderInvalid}
describedBy={describedBy}
onEnter={apply}
onValueChange={(startTime) =>
setDraft((current) => ({ ...current, startTime }))
}
/>
<TimeField
id={`${triggerId}-end-time`}
label={endTimeLabel}
value={draft.endTime}
precision={precision}
hourCycle={cycle}
locale={locale}
unitLabels={timeUnitLabels}
disabled={disabled}
readOnly={readOnly}
invalid={resolved.endTimeInvalid || resolved.orderInvalid}
describedBy={describedBy}
onEnter={apply}
onValueChange={(endTime) =>
setDraft((current) => ({ ...current, endTime }))
}
/>
</div>
<p
id={errorId}
role="status"
aria-live="polite"
className="text-sm text-destructive empty:hidden"
>
{resolved.orderInvalid ? rangeErrorLabel : null}
</p>
<PickerFooter
showClear={Boolean(clearable && committed?.from && !blocked)}
canApply={canApply}
whileTap={whileTap}
labels={{
apply: applyLabel,
cancel: cancelLabel,
clear: clearLabel,
}}
onClear={() => commit()}
onCancel={() => setOpen(false)}
onApply={apply}
/>
</div>
</PopoverContent>
</Popover>
</div>
);
};
Credits
- shadcn/ui: Button, Popover, and Calendar composition pattern; Vandor adds the start/end time columns, the Apply/Cancel draft flow, range validation, precision control, and motion controls.