Date Range Picker
Inputs & Forms

Date Range Picker

A two-month range picker with click-click selection, a live hover preview band shared across panes, an animated preset rail, and an apply/cancel footer.

Install

npx shadcn@latest add @paragon/date-range-picker

Also installs: calendar, popover, button

date-range-picker.tsx

"use client";

import * as React from "react";
import { CalendarRange as CalendarRangeIcon } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import {
  addDays,
  addMonths,
  Calendar,
  compareDay,
  formatDate,
  sameDay,
  startOfDay,
  type CalendarRange,
} from "@/registry/paragon/ui/calendar";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/registry/paragon/ui/popover";
import { Button } from "@/registry/paragon/ui/button";
import { cn } from "@/lib/utils";

interface Preset {
  key: string;
  label: string;
  range: (today: Date) => CalendarRange;
}

const PRESETS: Preset[] = [
  { key: "today", label: "Today", range: (t) => ({ from: t, to: t }) },
  {
    key: "7d",
    label: "Last 7 days",
    range: (t) => ({ from: addDays(t, -6), to: t }),
  },
  {
    key: "30d",
    label: "Last 30 days",
    range: (t) => ({ from: addDays(t, -29), to: t }),
  },
  {
    key: "mtd",
    label: "Month to date",
    range: (t) => ({ from: new Date(t.getFullYear(), t.getMonth(), 1), to: t }),
  },
  {
    key: "qtd",
    label: "Quarter to date",
    range: (t) => ({
      from: new Date(t.getFullYear(), Math.floor(t.getMonth() / 3) * 3, 1),
      to: t,
    }),
  },
];

const rangesEqual = (a: CalendarRange, b: CalendarRange) =>
  Boolean(
    a.from && b.from && sameDay(a.from, b.from) &&
    a.to && b.to && sameDay(a.to, b.to),
  );

const rangeDays = (r: CalendarRange) =>
  r.from && r.to ? Math.round(compareDay(r.to, r.from) / 86400000) + 1 : 0;

export interface DateRangePickerProps {
  value?: CalendarRange;
  defaultValue?: CalendarRange;
  onValueChange?: (range: CalendarRange) => void;
  min?: Date;
  max?: Date;
  weekStartsOn?: 0 | 1;
  /** Reference "today" for presets and the today ring. */
  now?: Date;
  placeholder?: string;
  disabled?: boolean;
  align?: "start" | "center" | "end";
  className?: string;
  "aria-label"?: string;
}

/**
 * A two-month range picker: click-click selection with a live hover
 * preview band shared across both panes, a preset rail whose choices
 * animate the band into place, and an apply/cancel footer so nothing
 * commits until the user means it.
 */
export function DateRangePicker({
  value: valueProp,
  defaultValue = { from: null, to: null },
  onValueChange,
  min,
  max,
  weekStartsOn = 1,
  now,
  placeholder = "Pick a date range",
  disabled = false,
  align = "start",
  className,
  "aria-label": ariaLabel = "Date range",
}: DateRangePickerProps) {
  const reduced = useReducedMotion() ?? false;
  const [fallbackNow] = React.useState(() => startOfDay(new Date()));
  const today = now ? startOfDay(now) : fallbackNow;

  const [valueState, setValueState] = React.useState<CalendarRange>(defaultValue);
  const committed = valueProp !== undefined ? valueProp : valueState;

  const [open, setOpen] = React.useState(false);
  const [draft, setDraft] = React.useState<CalendarRange>(committed);
  const [hover, setHover] = React.useState<Date | null>(null);
  const [month, setMonth] = React.useState<Date>(
    () => committed.from ?? addMonths(today, -1),
  );

  const openWith = (next: boolean) => {
    if (next) {
      setDraft(committed);
      setMonth(committed.from ?? addMonths(today, -1));
    }
    setOpen(next);
  };

  const apply = () => {
    setValueState(draft);
    onValueChange?.(draft);
    setOpen(false);
  };

  const applyPreset = (preset: Preset) => {
    const next = preset.range(today);
    setDraft(next);
    setHover(null);
    // Land the band in view: end month on the right pane.
    if (next.to) setMonth(addMonths(next.to, -1));
  };

  const activePreset =
    PRESETS.find((p) => rangesEqual(p.range(today), draft))?.key ??
    (draft.from ? "custom" : null);

  const label =
    committed.from && committed.to
      ? `${formatDate(committed.from)} – ${formatDate(committed.to)}`
      : placeholder;

  const draftLabel = draft.from
    ? draft.to
      ? `${formatDate(draft.from)} – ${formatDate(draft.to)}`
      : `${formatDate(draft.from)} – …`
    : "Select a start date";

  const presetButton = (key: string, text: string, onClick: () => void) => {
    const active = activePreset === key;
    return (
      <button
        key={key}
        type="button"
        onClick={onClick}
        aria-pressed={active}
        className={cn(
          "pressable relative rounded-md px-2.5 py-1.5 text-left text-[13px] outline-none transition-colors duration-150 ease-out focus-visible:ring-2 focus-visible:ring-ring",
          active
            ? "font-medium text-foreground"
            : "text-muted-foreground hover:text-foreground",
        )}
      >
        {active && (
          <motion.span
            layoutId="pg-range-preset"
            layout={!reduced}
            aria-hidden
            transition={{ type: "spring", duration: 0.3, bounce: 0 }}
            className="absolute inset-0 rounded-md bg-accent"
          />
        )}
        <span className="relative z-10">{text}</span>
      </button>
    );
  };

  return (
    <Popover open={open} onOpenChange={openWith}>
      <PopoverTrigger asChild>
        <Button
          variant="outline"
          disabled={disabled}
          aria-label={ariaLabel}
          className={cn(
            "w-fit font-normal tabular-nums",
            !committed.from && "text-muted-foreground",
            className,
          )}
        >
          <CalendarRangeIcon className="size-4 text-muted-foreground" aria-hidden />
          {label}
        </Button>
      </PopoverTrigger>
      <PopoverContent align={align} sideOffset={8} className="w-auto p-0">
        <div className="flex">
          <div
            className="flex w-36 shrink-0 flex-col gap-0.5 border-r border-border p-2"
            role="group"
            aria-label="Range presets"
          >
            {PRESETS.map((p) => presetButton(p.key, p.label, () => applyPreset(p)))}
            {presetButton("custom", "Custom", () => {
              setDraft({ from: null, to: null });
              setHover(null);
            })}
          </div>
          <div className="flex gap-4 p-3">
            <Calendar
              aria-label="Start month"
              mode="range"
              now={today}
              range={draft}
              onRangeChange={setDraft}
              hoverDate={hover}
              onHoverDateChange={setHover}
              month={month}
              onMonthChange={setMonth}
              nav="prev"
              hideOutsideDays
              min={min}
              max={max}
              weekStartsOn={weekStartsOn}
            />
            <Calendar
              aria-label="End month"
              mode="range"
              now={today}
              range={draft}
              onRangeChange={setDraft}
              hoverDate={hover}
              onHoverDateChange={setHover}
              month={addMonths(month, 1)}
              onMonthChange={(m) => setMonth(addMonths(m, -1))}
              nav="next"
              hideOutsideDays
              min={min}
              max={max}
              weekStartsOn={weekStartsOn}
            />
          </div>
        </div>
        <div className="flex items-center justify-between gap-4 border-t border-border px-3 py-2.5">
          <p className="min-w-0 truncate text-[13px] text-muted-foreground tabular-nums">
            {draftLabel}
            {draft.from && draft.to && (
              <span className="text-muted-foreground/60">
                {" "}
                · {rangeDays(draft)} {rangeDays(draft) === 1 ? "day" : "days"}
              </span>
            )}
          </p>
          <div className="flex shrink-0 gap-2">
            <Button variant="ghost" size="sm" onClick={() => setOpen(false)}>
              Cancel
            </Button>
            <Button size="sm" onClick={apply} disabled={!draft.from || !draft.to}>
              Apply
            </Button>
          </div>
        </div>
      </PopoverContent>
    </Popover>
  );
}