# Date Time Range Picker

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

> For the complete documentation index, see [llms.txt](/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](/.well-known/agent-skills/site-skill.md).





## Playground [#playground]

```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 [#installation]

Initialize shadcn in your React project, then install this component from the Vandor UI registry:

```bash
npx shadcn@latest add https://vandor-ui.vercel.app/r/date-time-range-picker.json
```

## Dependencies [#dependencies]

Installed automatically by the registry CLI. For manual setup:

```bash
npm install 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 [#usage]

```tsx
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 `Date`s 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](/docs/components/date-time-picker) and shares its time field and precision rules. It differs from [Date Range Picker](/docs/components/date-range-picker), which selects whole days. `motion={false}` disables popup, Calendar, and button animations; system reduced-motion preferences take priority.

## Consumer setup [#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) [#storybook-stories-optional]

Install DateTimeRangePicker first, then:

```bash
npx 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 [#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.

<ComponentPreview name="date-time-range-picker-demo" title="date-time-range-picker-demo.tsx">
  <DateTimeRangePickerDemo />
</ComponentPreview>

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.

<ComponentPreview name="date-time-range-picker-24h-demo" title="date-time-range-picker-24h-demo.tsx">
  <DateTimeRangePicker24hDemo />
</ComponentPreview>

## Behavior & Accessibility [#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 [#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](/docs/components/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 [#props]

| Prop            | Type                                                                 | Default                                 | Description                                                                                                                                                                |
| --------------- | -------------------------------------------------------------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| motion          | boolean                                                              | true                                    | Enables popup, calendar, and button motion; system reduced-motion takes priority.                                                                                          |
| applyLabel      | string                                                               | "Apply"                                 | DateTimeRangePicker: apply button text.                                                                                                                                    |
| cancelLabel     | string                                                               | "Cancel"                                | DateTimeRangePicker: cancel button text.                                                                                                                                   |
| className       | string                                                               | undefined                               | DateTimeRangePicker: trigger class name.                                                                                                                                   |
| clearLabel      | string                                                               | "Clear"                                 | DateTimeRangePicker: clear button text.                                                                                                                                    |
| clearable       | boolean                                                              | false                                   | DateTimeRangePicker: shows a Clear action that commits an empty value immediately.                                                                                         |
| dateFormat      | string                                                               | "PPP p"                                 | 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.                          |
| defaultOpen     | boolean                                                              | false                                   | DateTimeRangePicker: initial uncontrolled popover visibility.                                                                                                              |
| defaultValue    | DateRange \| undefined                                               | undefined                               | DateTimeRangePicker: initial uncontrolled range with local start and end times.                                                                                            |
| disabled        | boolean                                                              | false                                   | DateTimeRangePicker: disables opening the trigger and closes an open popup.                                                                                                |
| disabledDates   | Matcher \| Matcher\[]                                                | undefined                               | DateTimeRangePicker: calendar days that cannot be part of a range. Time is not constrained.                                                                                |
| endMonth        | Date                                                                 | undefined                               | DateTimeRangePicker: latest navigable month.                                                                                                                               |
| hourCycle       | 12 \| 24                                                             | from locale                             | 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. |
| timeUnitLabels  | \{ hours: string; minutes: string; seconds: string; period: string } | English labels                          | DateTimeRangePicker: accessible names of the hour, minute, second and AM/PM columns.                                                                                       |
| id              | string                                                               | generated                               | DateTimeRangePicker: optional stable id for the label relationship.                                                                                                        |
| label           | string                                                               | required                                | DateTimeRangePicker: visible accessible name.                                                                                                                              |
| locale          | date-fns Locale                                                      | undefined                               | DateTimeRangePicker: display and calendar locale.                                                                                                                          |
| onOpenChange    | (open: boolean) => void                                              | undefined                               | DateTimeRangePicker: observes popover visibility changes.                                                                                                                  |
| onValueChange   | (range: DateRange \| undefined) => void                              | undefined                               | DateTimeRangePicker: fires on Apply with a complete \{ from, to }, or on Clear with undefined. Never fires while editing the draft.                                        |
| open            | boolean                                                              | uncontrolled                            | DateTimeRangePicker: controlled popover visibility.                                                                                                                        |
| placeholder     | string                                                               | "Select date and time range"            | DateTimeRangePicker: trigger text when empty.                                                                                                                              |
| precision       | "minute" \| "second"                                                 | "minute"                                | DateTimeRangePicker: time granularity. Emitted dates always have zero milliseconds, and zero seconds at minute precision.                                                  |
| readOnly        | boolean                                                              | false                                   | DateTimeRangePicker: opens the popup but prevents changes.                                                                                                                 |
| startMonth      | Date                                                                 | undefined                               | DateTimeRangePicker: earliest navigable month.                                                                                                                             |
| startTimeLabel  | string                                                               | "Start time"                            | DateTimeRangePicker: start time field label.                                                                                                                               |
| endTimeLabel    | string                                                               | "End time"                              | DateTimeRangePicker: end time field label.                                                                                                                                 |
| rangeErrorLabel | string                                                               | "The end must not be before the start." | DateTimeRangePicker: message shown, and linked to both time fields, when the end is before the start.                                                                      |
| value           | DateRange \| undefined                                               | uncontrolled                            | DateTimeRangePicker: controlled range with local start and end times.                                                                                                      |

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 [#source]

```tsx
"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 [#credits]

* [shadcn/ui](https://ui.shadcn.com/docs/components/base/date-picker): 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.
