All componentsform
Date Range Picker
Click a start date, then an end date. Focus a day and use arrow keys, Home/End, or PageUp/PageDown to navigate.
August 2026
SuMoTuWeThFrSa
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
September 2026
SuMoTuWeThFrSa
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
From: None · To: None
Installation
$ npx shadcn@latest add https://deepmoon.dev/r/date-range-picker.jsonnpm dependencies: lucide-react
Props
DateRangePickerProps
| Prop | Type | Required |
|---|---|---|
| value | DateRange | Yes |
| onChange | (value: DateRange) => void | Yes |
| className | string | No |
Code
date-range-picker.tsx
"use client";
import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
export interface DateRange {
from?: Date;
to?: Date;
}
export interface DateRangePickerProps {
value: DateRange;
onChange: (value: DateRange) => void;
className?: string;
}
const WEEKDAY_LABELS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"];
function startOfDay(date: Date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
function isSameDay(a?: Date, b?: Date) {
return !!a && !!b && a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
}
function isBefore(a: Date, b: Date) {
return startOfDay(a).getTime() < startOfDay(b).getTime();
}
function addDays(date: Date, days: number) {
const next = new Date(date);
next.setDate(next.getDate() + days);
return next;
}
/** Normalizes to the 1st — used for tracking which month a panel shows. */
function addMonths(date: Date, months: number) {
return new Date(date.getFullYear(), date.getMonth() + months, 1);
}
/** Keeps the day-of-month (clamped to the target month's length) — used for PageUp/PageDown, which move the focused day, not a panel. */
function addMonthsPreserveDay(date: Date, months: number) {
const day = date.getDate();
const targetFirst = addMonths(date, months);
const daysInTarget = new Date(targetFirst.getFullYear(), targetFirst.getMonth() + 1, 0).getDate();
return new Date(targetFirst.getFullYear(), targetFirst.getMonth(), Math.min(day, daysInTarget));
}
function isInRange(date: Date, range: DateRange) {
if (!range.from || !range.to) return false;
const [start, end] = isBefore(range.from, range.to) ? [range.from, range.to] : [range.to, range.from];
return !isBefore(date, start) && !isBefore(end, date);
}
function dayId(baseId: string, date: Date) {
return `${baseId}-day-${date.toISOString().slice(0, 10)}`;
}
function buildMonthGrid(monthDate: Date): (Date | null)[][] {
const year = monthDate.getFullYear();
const month = monthDate.getMonth();
const firstDay = new Date(year, month, 1);
const daysInMonth = new Date(year, month + 1, 0).getDate();
const startWeekday = firstDay.getDay();
const cells: (Date | null)[] = [];
for (let i = 0; i < startWeekday; i++) cells.push(null);
for (let d = 1; d <= daysInMonth; d++) cells.push(new Date(year, month, d));
while (cells.length % 7 !== 0) cells.push(null);
const weeks: (Date | null)[][] = [];
for (let i = 0; i < cells.length; i += 7) weeks.push(cells.slice(i, i + 7));
return weeks;
}
interface MonthGridProps {
monthDate: Date;
value: DateRange;
focusedDate: Date;
hoveredDate: Date | null;
today: Date;
onSelect: (date: Date) => void;
onFocusDate: (date: Date) => void;
onHoverDate: (date: Date | null) => void;
onKeyDown: (event: React.KeyboardEvent, date: Date) => void;
baseId: string;
}
function MonthGrid({
monthDate,
value,
focusedDate,
hoveredDate,
today,
onSelect,
onFocusDate,
onHoverDate,
onKeyDown,
baseId,
}: MonthGridProps) {
const weeks = buildMonthGrid(monthDate);
const label = monthDate.toLocaleDateString(undefined, { month: "long", year: "numeric" });
const previewRange: DateRange = value.from && !value.to && hoveredDate ? { from: value.from, to: hoveredDate } : value;
return (
<div role="grid" aria-label={label} className="flex flex-col gap-1">
<p className="px-1 text-sm font-semibold text-foreground">{label}</p>
<div role="row" className="grid grid-cols-7">
{WEEKDAY_LABELS.map((day) => (
<span
key={day}
role="columnheader"
aria-label={day}
className="flex h-8 items-center justify-center text-xs font-medium text-muted-foreground"
>
{day}
</span>
))}
</div>
{weeks.map((week, weekIndex) => (
<div role="row" key={weekIndex} className="grid grid-cols-7">
{week.map((date, dayIndex) => {
if (!date) return <span key={dayIndex} aria-hidden="true" />;
const selectedEdge = isSameDay(date, value.from) || isSameDay(date, value.to);
const inRange = isInRange(date, previewRange);
const isFocusTarget = isSameDay(date, focusedDate);
const isToday = isSameDay(date, today);
return (
<div
key={dayIndex}
id={dayId(baseId, date)}
role="gridcell"
aria-selected={selectedEdge || inRange}
aria-current={isToday ? "date" : undefined}
tabIndex={isFocusTarget ? 0 : -1}
onClick={() => onSelect(date)}
onFocus={() => onFocusDate(date)}
onMouseEnter={() => onHoverDate(date)}
onMouseLeave={() => onHoverDate(null)}
onKeyDown={(event) => onKeyDown(event, date)}
className={cn(
"flex h-8 w-8 cursor-pointer select-none items-center justify-center rounded-md text-sm outline-none",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
inRange && !selectedEdge && "bg-accent text-accent-foreground",
selectedEdge && "bg-primary text-primary-foreground",
!selectedEdge && !inRange && "text-foreground hover:bg-accent",
isToday && !selectedEdge && "font-semibold",
)}
>
{date.getDate()}
</div>
);
})}
</div>
))}
</div>
);
}
/**
* Two role="grid" calendars sharing one roving-tabindex focus target
* (focusedDate). Arrow keys move within the currently visible two-month
* span; crossing past either edge shifts both panels forward/back by one
* month and refocuses the target day there — this covers real navigation
* (including PageUp/PageDown month-jumps) without needing an unbounded
* wrap-around scheme. The live range preview while hovering a potential
* end date is a mouse-only enhancement; keyboard selection (Enter on the
* start, then Enter on the end) works identically either way, it just
* doesn't preview the span before committing.
*/
export function DateRangePicker({ value, onChange, className }: DateRangePickerProps) {
const today = React.useMemo(() => startOfDay(new Date()), []);
const [baseMonth, setBaseMonth] = React.useState(() => addMonths(value.from ?? today, 0));
const [focusedDate, setFocusedDate] = React.useState(() => value.from ?? today);
const [hoveredDate, setHoveredDate] = React.useState<Date | null>(null);
const baseId = React.useId();
const secondMonth = addMonths(baseMonth, 1);
const rangeStart = startOfDay(baseMonth);
const rangeEnd = new Date(secondMonth.getFullYear(), secondMonth.getMonth() + 1, 0);
const selectDate = (date: Date) => {
if (!value.from || (value.from && value.to)) {
onChange({ from: date, to: undefined });
} else {
const from = value.from;
onChange(isBefore(date, from) ? { from: date, to: from } : { from, to: date });
}
};
const moveFocus = (next: Date) => {
setFocusedDate(next);
if (isBefore(next, rangeStart)) setBaseMonth(addMonths(baseMonth, -1));
else if (isBefore(rangeEnd, next)) setBaseMonth(addMonths(baseMonth, 1));
requestAnimationFrame(() => {
document.getElementById(dayId(baseId, next))?.focus();
});
};
const handleKeyDown = (event: React.KeyboardEvent, date: Date) => {
let next: Date | null = null;
if (event.key === "ArrowRight") next = addDays(date, 1);
else if (event.key === "ArrowLeft") next = addDays(date, -1);
else if (event.key === "ArrowDown") next = addDays(date, 7);
else if (event.key === "ArrowUp") next = addDays(date, -7);
else if (event.key === "Home") next = addDays(date, -date.getDay());
else if (event.key === "End") next = addDays(date, 6 - date.getDay());
else if (event.key === "PageDown") next = addMonthsPreserveDay(date, 1);
else if (event.key === "PageUp") next = addMonthsPreserveDay(date, -1);
else if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
selectDate(date);
return;
}
if (!next) return;
event.preventDefault();
moveFocus(next);
};
return (
<div role="group" aria-label="Date range" className={cn("inline-flex flex-col gap-4 rounded-lg border border-border bg-card p-4", className)}>
<div className="flex items-start gap-4 sm:gap-8">
<button
type="button"
aria-label="Previous month"
onClick={() => setBaseMonth(addMonths(baseMonth, -1))}
className="mt-6 flex size-7 shrink-0 items-center justify-center rounded-md text-foreground outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
>
<ChevronLeft className="size-4" aria-hidden="true" />
</button>
<div className="flex flex-1 flex-col gap-4 sm:flex-row sm:gap-8">
<MonthGrid
monthDate={baseMonth}
value={value}
focusedDate={focusedDate}
hoveredDate={hoveredDate}
today={today}
onSelect={selectDate}
onFocusDate={setFocusedDate}
onHoverDate={setHoveredDate}
onKeyDown={handleKeyDown}
baseId={baseId}
/>
<MonthGrid
monthDate={secondMonth}
value={value}
focusedDate={focusedDate}
hoveredDate={hoveredDate}
today={today}
onSelect={selectDate}
onFocusDate={setFocusedDate}
onHoverDate={setHoveredDate}
onKeyDown={handleKeyDown}
baseId={baseId}
/>
</div>
<button
type="button"
aria-label="Next month"
onClick={() => setBaseMonth(addMonths(baseMonth, 1))}
className="mt-6 flex size-7 shrink-0 items-center justify-center rounded-md text-foreground outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
>
<ChevronRight className="size-4" aria-hidden="true" />
</button>
</div>
</div>
);
}