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.
npx shadcn@latest add @paragon/date-range-pickerAlso installs: calendar, popover, button
"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>
);
}