For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to any URL or sending an Accept: text/markdown header. An agent skill is available at /.well-known/agent-skills/site-skill.md.
1
Support

Date Time Range Picker

A transactional date and time range picker combining a calendar and two time pickers in an accessible popover.

Playground

Live preview
Choose days and start/end times as a draft, then Apply. One clicked day is a same-day window; an end before the start blocks Apply.
Customize date-time-range-picker
Customize
date-time-range-picker-demo.tsx
"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:

Dependencies4 required packages
  • 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.2

Copying 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.

date-time-range-picker-demo.tsx
"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.

date-time-range-picker-24h-demo.tsx
"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

Component props, types, defaults, and descriptions
PropType / DescriptionDefault
motionboolean

Enables popup, calendar, and button motion; system reduced-motion takes priority.

true
applyLabelstring

DateTimeRangePicker: apply button text.

"Apply"
cancelLabelstring

DateTimeRangePicker: cancel button text.

"Cancel"
classNamestring

DateTimeRangePicker: trigger class name.

undefined
clearLabelstring

DateTimeRangePicker: clear button text.

"Clear"
clearableboolean

DateTimeRangePicker: shows a Clear action that commits an empty value immediately.

false
dateFormatstring

DateTimeRangePicker: 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"
defaultOpenboolean

DateTimeRangePicker: initial uncontrolled popover visibility.

false
defaultValueDateRange | undefined

DateTimeRangePicker: initial uncontrolled range with local start and end times.

undefined
disabledboolean

DateTimeRangePicker: disables opening the trigger and closes an open popup.

false
disabledDatesMatcher | Matcher[]

DateTimeRangePicker: calendar days that cannot be part of a range. Time is not constrained.

undefined
endMonthDate

DateTimeRangePicker: latest navigable month.

undefined
hourCycle12 | 24

DateTimeRangePicker: 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
idstring

DateTimeRangePicker: optional stable id for the label relationship.

generated
labelstring

DateTimeRangePicker: visible accessible name.

required
localedate-fns Locale

DateTimeRangePicker: display and calendar locale.

undefined
onOpenChange(open: boolean) => void

DateTimeRangePicker: observes popover visibility changes.

undefined
onValueChange(range: DateRange | undefined) => void

DateTimeRangePicker: fires on Apply with a complete { from, to }, or on Clear with undefined. Never fires while editing the draft.

undefined
openboolean

DateTimeRangePicker: controlled popover visibility.

uncontrolled
placeholderstring

DateTimeRangePicker: 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"
readOnlyboolean

DateTimeRangePicker: opens the popup but prevents changes.

false
startMonthDate

DateTimeRangePicker: earliest navigable month.

undefined
startTimeLabelstring

DateTimeRangePicker: start time field label.

"Start time"
endTimeLabelstring

DateTimeRangePicker: end time field label.

"End time"
rangeErrorLabelstring

DateTimeRangePicker: message shown, and linked to both time fields, when the end is before the start.

"The end must not be before the start."
valueDateRange | undefined

DateTimeRangePicker: 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.