Date Range Picker
Dual-month range picker with hover preview, presets, min/max bounds and keyboard nav. Includes an inline DateRangeCalendar, no date library.
Examples
Presets
Inline calendar
Bounded, weekends disabled
Installation
npx shadcn@latest add https://hirael.com/r/date-range-picker.jsonAPI
<DateRangePicker />
| Prop | Type | Default |
|---|---|---|
value | DateRange | — |
defaultValue | DateRange | — |
onValueChange | ((range: DateRange | undefined) => void) | — |
open | boolean | — |
defaultOpen | boolean | false |
onOpenChange | ((open: boolean) => void) | — |
min | Date | — |
max | Date | — |
disabled | boolean | — |
children | React.ReactNode | — |
<DateRangePickerTrigger />
+ native element props| Prop | Type | Default |
|---|---|---|
placeholder | string | "Pick a date range" |
locale | string | — |
children | React.ReactNode | — |
<DateRangePickerContent />
+ native element props| Prop | Type | Default |
|---|---|---|
presets | DateRangePreset[] | DEFAULT_PRESETS |
showPresets | boolean | true |
locale | string | — |
weekStartsOn | 0 | 1 | — |
numberOfMonths | 1 | 2 | — |
disabledDate | ((d: Date) => boolean) | — |
<DateRangeCalendar />
| Prop | Type | Default |
|---|---|---|
value | DateRange | — |
defaultValue | DateRange | — |
onValueChange | ((range: DateRange | undefined) => void) | — |
min | Date | — |
max | Date | — |
disabledDate | ((d: Date) => boolean) | — |
locale | string | — |
weekStartsOn | 0 | 1 | 1 |
numberOfMonths | 1 | 2 | 2 |
className | string | — |
Component source
"use client";
import * as React from "react";
import { CalendarIcon, ChevronLeft, ChevronRight, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/registry/hirael/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/registry/hirael/ui/popover";
import { Separator } from "@/registry/hirael/ui/separator";
import {
addDays,
clampDate,
gridKeyToDate,
monthCells,
monthIndex,
sameDay,
startOfDay,
} from "@/registry/hirael/components/calendar-utils";
export type DateRange = { from?: Date; to?: Date };
export type DateRangePreset = {
label: string;
range: () => { from: Date; to: Date };
};
const DEFAULT_PRESETS: DateRangePreset[] = [
{
label: "Today",
range: () => {
const t = startOfDay(new Date());
return { from: t, to: t };
},
},
{
label: "Last 7 days",
range: () => {
const t = startOfDay(new Date());
return { from: addDays(t, -6), to: t };
},
},
{
label: "Last 14 days",
range: () => {
const t = startOfDay(new Date());
return { from: addDays(t, -13), to: t };
},
},
{
label: "Last 30 days",
range: () => {
const t = startOfDay(new Date());
return { from: addDays(t, -29), to: t };
},
},
{
label: "This month",
range: () => {
const t = new Date();
return {
from: new Date(t.getFullYear(), t.getMonth(), 1),
to: new Date(t.getFullYear(), t.getMonth() + 1, 0),
};
},
},
{
label: "Last month",
range: () => {
const t = new Date();
return {
from: new Date(t.getFullYear(), t.getMonth() - 1, 1),
to: new Date(t.getFullYear(), t.getMonth(), 0),
};
},
},
];
export type DateRangeCalendarProps = {
value?: DateRange;
defaultValue?: DateRange;
onValueChange?: (range: DateRange | undefined) => void;
min?: Date;
max?: Date;
disabledDate?: (d: Date) => boolean;
locale?: string;
weekStartsOn?: 0 | 1;
numberOfMonths?: 1 | 2;
className?: string;
};
function DateRangeCalendar({
value: valueProp,
defaultValue,
onValueChange,
min,
max,
disabledDate,
locale,
weekStartsOn = 1,
numberOfMonths = 2,
className,
}: DateRangeCalendarProps) {
const [internal, setInternal] = React.useState<DateRange | undefined>(
defaultValue,
);
const range = valueProp !== undefined ? valueProp : internal;
const today = startOfDay(new Date());
const [viewMonth, setViewMonth] = React.useState<Date>(() => {
const anchor = range?.from ?? today;
return new Date(anchor.getFullYear(), anchor.getMonth(), 1);
});
const [hovered, setHovered] = React.useState<Date | null>(null);
const pending = !!range?.from && !range?.to;
const setRange = React.useCallback(
(next: DateRange | undefined) => {
if (valueProp === undefined) setInternal(next);
onValueChange?.(next);
},
[valueProp, onValueChange],
);
const isDayDisabled = React.useCallback(
(d: Date) => {
if (min && d.getTime() < startOfDay(min).getTime()) return true;
if (max && d.getTime() > startOfDay(max).getTime()) return true;
return disabledDate ? disabledDate(d) : false;
},
[min, max, disabledDate],
);
const selectDay = (d: Date) => {
if (!range?.from || range.to) {
setRange({ from: d });
return;
}
setHovered(null);
if (d.getTime() < startOfDay(range.from).getTime()) {
setRange({ from: d, to: startOfDay(range.from) });
} else {
setRange({ from: startOfDay(range.from), to: d });
}
};
const fromDay = range?.from ? startOfDay(range.from) : undefined;
const toDay = range?.to ? startOfDay(range.to) : undefined;
const previewing = pending && !!hovered && !!fromDay;
const lo = previewing
? hovered.getTime() < fromDay.getTime()
? hovered
: fromDay
: fromDay;
const hi = previewing
? hovered.getTime() < fromDay.getTime()
? fromDay
: hovered
: toDay;
const hasSpan = !!lo && !!hi && !sameDay(lo, hi);
const months = Array.from(
{ length: numberOfMonths },
(_, i) => new Date(viewMonth.getFullYear(), viewMonth.getMonth() + i, 1),
);
const isMonthVisible = (d: Date) => {
const k = monthIndex(d);
const v = monthIndex(viewMonth);
return k >= v && k < v + numberOfMonths;
};
const canPrev =
!min ||
new Date(viewMonth.getFullYear(), viewMonth.getMonth(), 0).getTime() >=
startOfDay(min).getTime();
const canNext =
!max ||
new Date(
viewMonth.getFullYear(),
viewMonth.getMonth() + numberOfMonths,
1,
).getTime() <= startOfDay(max).getTime();
const stepMonth = (n: number) =>
setViewMonth(
new Date(viewMonth.getFullYear(), viewMonth.getMonth() + n, 1),
);
const rootRef = React.useRef<HTMLDivElement>(null);
const focusDay = (d: Date) => {
const doFocus = () => {
rootRef.current
?.querySelector<HTMLButtonElement>(`[data-day="${d.getTime()}"]`)
?.focus();
};
if (!isMonthVisible(d)) {
setViewMonth(new Date(d.getFullYear(), d.getMonth(), 1));
requestAnimationFrame(doFocus);
} else {
doFocus();
}
};
const handleKey = (e: React.KeyboardEvent, d: Date) => {
const forward =
getComputedStyle(e.currentTarget).direction === "rtl" ? -1 : 1;
const next = gridKeyToDate(e.key, d, {
weekStartsOn,
shiftKey: e.shiftKey,
forward,
});
if (!next) return;
e.preventDefault();
focusDay(clampDate(next, min, max));
};
const monthFmt = new Intl.DateTimeFormat(locale, {
month: "long",
year: "numeric",
});
const weekdayFmt = new Intl.DateTimeFormat(locale, { weekday: "short" });
const dayLabelFmt = new Intl.DateTimeFormat(locale, { dateStyle: "full" });
const weekdays = Array.from({ length: 7 }, (_, i) =>
weekdayFmt.format(new Date(2021, 7, 1 + ((weekStartsOn + i) % 7))),
);
const tabbable =
range?.from && isMonthVisible(range.from)
? startOfDay(range.from)
: isMonthVisible(today)
? today
: viewMonth;
return (
<div
ref={rootRef}
data-slot="date-range-calendar"
className={cn("flex gap-4", className)}
>
{months.map((month, mi) => {
const last = mi === numberOfMonths - 1;
const cells = monthCells(month, weekStartsOn);
const weeks: (Date | null)[][] = [];
for (let i = 0; i < cells.length; i += 7) {
weeks.push(cells.slice(i, i + 7));
}
return (
<div
key={monthIndex(month)}
data-slot="date-range-calendar-month"
className={cn("w-60", mi > 0 && "max-sm:hidden")}
>
<div
data-slot="date-range-calendar-header"
className="mb-2 flex items-center justify-between"
>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Previous month"
disabled={!canPrev}
onClick={() => stepMonth(-1)}
className={cn("size-7", mi > 0 && "invisible")}
>
<ChevronLeft className="size-3.5 rtl:rotate-180" />
</Button>
<span
data-slot="date-range-calendar-caption"
className="font-mono text-[11px] tabular-nums uppercase tracking-[0.08em] text-muted-foreground"
>
{monthFmt.format(month)}
</span>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Next month"
disabled={!canNext}
onClick={() => stepMonth(1)}
className={cn(
"size-7",
!last && numberOfMonths === 2 && "sm:invisible",
)}
>
<ChevronRight className="size-3.5 rtl:rotate-180" />
</Button>
</div>
<div
role="grid"
aria-label={monthFmt.format(month)}
data-slot="date-range-calendar-grid"
className="grid gap-y-0.5"
onMouseLeave={() => setHovered(null)}
>
<div role="row" className="grid grid-cols-7">
{weekdays.map((label, i) => (
<span
key={i}
role="columnheader"
data-slot="date-range-calendar-weekday"
className="flex h-7 items-center justify-center font-mono text-[10px] uppercase text-muted-foreground"
>
{label}
</span>
))}
</div>
{weeks.map((week, wi) => (
<div key={wi} role="row" className="grid grid-cols-7">
{week.map((d, i) => {
if (!d) {
return (
<span key={i} role="gridcell" className="size-8" />
);
}
const t = d.getTime();
const isLo = !!lo && sameDay(d, lo);
const isHi = !!hi && sameDay(d, hi);
const isBetween =
!!lo && !!hi && t > lo.getTime() && t < hi.getTime();
const isAnchor =
sameDay(d, range?.from) || sameDay(d, range?.to);
const isSelected =
isAnchor ||
(!!fromDay &&
!!toDay &&
t > fromDay.getTime() &&
t < toDay.getTime());
const isToday = sameDay(d, today);
const out = isDayDisabled(d);
return (
<button
key={i}
type="button"
role="gridcell"
data-day={t}
data-slot="date-range-calendar-day"
disabled={out}
aria-selected={isSelected}
aria-current={isToday ? "date" : undefined}
aria-label={dayLabelFmt.format(d)}
data-preview={
(previewing &&
(isLo || isHi || isBetween) &&
!isSelected) ||
undefined
}
onClick={() => selectDay(d)}
onKeyDown={(e) => handleKey(e, d)}
onMouseEnter={() => pending && setHovered(d)}
onFocus={() => pending && setHovered(d)}
tabIndex={sameDay(d, tabbable) ? 0 : -1}
className={cn(
"relative size-8 rounded-sm font-mono text-xs tabular-nums outline-none transition-colors",
"hover:bg-accent hover:text-accent-foreground",
"focus-visible:ring-2 focus-visible:ring-ring",
"disabled:opacity-30 disabled:hover:bg-transparent",
isBetween &&
"rounded-none bg-accent text-accent-foreground",
(isLo || isHi) &&
!isAnchor &&
"bg-accent text-accent-foreground",
isAnchor &&
"bg-primary text-primary-foreground hover:bg-primary",
isLo && hasSpan && "rounded-e-none",
isHi && hasSpan && "rounded-s-none",
!isAnchor &&
isToday &&
"ring-1 ring-inset ring-primary/60",
)}
>
{d.getDate()}
</button>
);
})}
</div>
))}
</div>
</div>
);
})}
</div>
);
}
type DateRangePickerContextValue = {
range: DateRange | undefined;
setRange: (range: DateRange | undefined) => void;
open: boolean;
setOpen: (open: boolean) => void;
min?: Date;
max?: Date;
disabled?: boolean;
};
const DateRangePickerContext =
React.createContext<DateRangePickerContextValue | null>(null);
function useDateRangePicker() {
const ctx = React.useContext(DateRangePickerContext);
if (!ctx) {
throw new Error(
"DateRangePicker compound components must be used inside <DateRangePicker>",
);
}
return ctx;
}
export type DateRangePickerProps = {
value?: DateRange;
defaultValue?: DateRange;
onValueChange?: (range: DateRange | undefined) => void;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
min?: Date;
max?: Date;
disabled?: boolean;
children?: React.ReactNode;
};
function DateRangePicker({
value: valueProp,
defaultValue,
onValueChange,
open: openProp,
defaultOpen = false,
onOpenChange,
min,
max,
disabled,
children,
}: DateRangePickerProps) {
const [internalRange, setInternalRange] = React.useState<
DateRange | undefined
>(defaultValue);
const range = valueProp !== undefined ? valueProp : internalRange;
const setRange = React.useCallback(
(next: DateRange | undefined) => {
if (valueProp === undefined) setInternalRange(next);
onValueChange?.(next);
},
[valueProp, onValueChange],
);
const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
const open = openProp !== undefined ? openProp : internalOpen;
const setOpen = React.useCallback(
(next: boolean) => {
if (openProp === undefined) setInternalOpen(next);
onOpenChange?.(next);
},
[openProp, onOpenChange],
);
const ctx = React.useMemo<DateRangePickerContextValue>(
() => ({ range, setRange, open, setOpen, min, max, disabled }),
[range, setRange, open, setOpen, min, max, disabled],
);
return (
<DateRangePickerContext.Provider value={ctx}>
<Popover open={open} onOpenChange={setOpen}>
{children}
</Popover>
</DateRangePickerContext.Provider>
);
}
function DateRangePickerTrigger({
placeholder = "Pick a date range",
locale,
className,
children,
...props
}: Omit<React.ComponentProps<"button">, "children"> & {
placeholder?: string;
locale?: string;
children?: React.ReactNode;
}) {
const ctx = useDateRangePicker();
const fmt = new Intl.DateTimeFormat(locale, { dateStyle: "medium" });
const label = ctx.range?.from
? ctx.range.to
? `${fmt.format(ctx.range.from)} – ${fmt.format(ctx.range.to)}`
: `${fmt.format(ctx.range.from)} – …`
: placeholder;
return (
<PopoverTrigger asChild>
<button
type="button"
disabled={ctx.disabled}
data-slot="date-range-picker-trigger"
data-state={ctx.open ? "open" : "closed"}
className={cn(
"inline-flex h-9 w-full items-center gap-2 rounded-sm border border-input bg-transparent px-3 text-start text-sm font-mono tabular-nums outline-none transition-colors",
"hover:border-ring/60 focus-visible:border-ring data-[state=open]:border-ring",
!ctx.range?.from && "text-muted-foreground font-sans",
"disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<CalendarIcon className="size-3.5 shrink-0 text-muted-foreground" />
<span
data-slot="date-range-picker-trigger-label"
className="flex-1 truncate"
>
{children ?? label}
</span>
</button>
</PopoverTrigger>
);
}
function DateRangePickerContent({
presets = DEFAULT_PRESETS,
showPresets = true,
locale,
weekStartsOn,
numberOfMonths,
disabledDate,
align = "start",
className,
...props
}: React.ComponentProps<typeof PopoverContent> & {
presets?: DateRangePreset[];
showPresets?: boolean;
locale?: string;
weekStartsOn?: 0 | 1;
numberOfMonths?: 1 | 2;
disabledDate?: (d: Date) => boolean;
}) {
const ctx = useDateRangePicker();
const isDayDisabled = (d: Date) => {
if (ctx.min && d.getTime() < startOfDay(ctx.min).getTime()) return true;
if (ctx.max && d.getTime() > startOfDay(ctx.max).getTime()) return true;
return disabledDate ? disabledDate(d) : false;
};
const resolvePreset = (preset: DateRangePreset): DateRange | null => {
const raw = preset.range();
const rawFrom = startOfDay(raw.from);
const rawTo = startOfDay(raw.to);
if (ctx.min && rawTo.getTime() < startOfDay(ctx.min).getTime()) return null;
if (ctx.max && rawFrom.getTime() > startOfDay(ctx.max).getTime())
return null;
let from = clampDate(rawFrom, ctx.min, ctx.max);
let to = clampDate(rawTo, ctx.min, ctx.max);
while (from.getTime() <= to.getTime() && isDayDisabled(from)) {
from = addDays(from, 1);
}
while (to.getTime() >= from.getTime() && isDayDisabled(to)) {
to = addDays(to, -1);
}
if (from.getTime() > to.getTime()) return null;
return { from, to };
};
return (
<PopoverContent
align={align}
data-slot="date-range-picker-content"
className={cn("w-auto p-3", className)}
{...props}
>
<div
data-slot="date-range-picker-content-body"
className="flex flex-col gap-3 sm:flex-row"
>
{showPresets && presets.length > 0 && (
<>
<div
data-slot="date-range-picker-presets"
className="flex flex-row flex-wrap gap-0.5 sm:w-32 sm:flex-col"
>
{presets.map((preset) => {
const resolved = resolvePreset(preset);
return (
<button
key={preset.label}
type="button"
data-slot="date-range-picker-preset"
disabled={!resolved}
onClick={() => {
if (!resolved) return;
ctx.setRange(resolved);
ctx.setOpen(false);
}}
className={cn(
"rounded-sm px-2 py-1.5 text-start text-xs outline-none transition-colors",
"hover:bg-accent hover:text-accent-foreground",
"focus-visible:ring-2 focus-visible:ring-ring",
"disabled:opacity-30 disabled:hover:bg-transparent",
)}
>
{preset.label}
</button>
);
})}
</div>
<Separator
orientation="vertical"
className="max-sm:hidden h-auto"
/>
<Separator className="sm:hidden" />
</>
)}
<div
data-slot="date-range-picker-content-calendar"
className="flex flex-col gap-2"
>
<DateRangeCalendar
value={ctx.range ?? {}}
onValueChange={ctx.setRange}
min={ctx.min}
max={ctx.max}
disabledDate={disabledDate}
locale={locale}
weekStartsOn={weekStartsOn}
numberOfMonths={numberOfMonths}
/>
{ctx.range?.from && (
<div
data-slot="date-range-picker-content-footer"
className="flex justify-end"
>
<Button
type="button"
variant="ghost"
size="sm"
data-slot="date-range-picker-clear"
onClick={() => ctx.setRange(undefined)}
className="h-7 gap-1 px-2 font-mono text-[10px] uppercase tracking-[0.08em] text-muted-foreground"
>
<X className="size-3" />
Clear
</Button>
</div>
)}
</div>
</div>
</PopoverContent>
);
}
export {
DateRangePicker,
DateRangePickerTrigger,
DateRangePickerContent,
DateRangeCalendar,
};
Dependencies
shadcn registry
buttonpopoverseparatorcalendar-utils
npm
lucide-react