Date range pickers are used in booking systems, analytics dashboards, and reporting tools. Building one that is fully accessible ensures all users can interact with it.
Date Utilities
export function getDaysInMonth(year: number, month: number): number {
return new Date(year, month + 1, 0).getDate();
}
export function getFirstDayOfMonth(year: number, month: number): number {
return new Date(year, month, 1).getDay();
}
export function isSameDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
export function isInRange(date: Date, start: Date | null, end: Date | null): boolean {
if (!start || !end) return false;
const time = date.getTime();
return time >= start.getTime() && time <= end.getTime();
}
export function formatDate(date: Date, locale = "en-US"): string {
return date.toLocaleDateString(locale, {
year: "numeric",
month: "short",
day: "numeric",
});
}
export interface DateRange {
start: Date | null;
end: Date | null;
}
export interface PresetRange {
label: string;
range: DateRange;
}
export function getPresets(): PresetRange[] {
const today = new Date();
today.setHours(0, 0, 0, 0);
const days = (n: number) => {
const d = new Date(today);
d.setDate(d.getDate() - n);
return d;
};
return [
{ label: "Last 7 days", range: { start: days(7), end: today } },
{ label: "Last 30 days", range: { start: days(30), end: today } },
{ label: "Last 90 days", range: { start: days(90), end: today } },
{ label: "This month", range: { start: new Date(today.getFullYear(), today.getMonth(), 1), end: today } },
{ label: "Last month", range: { start: new Date(today.getFullYear(), today.getMonth() - 1, 1), end: new Date(today.getFullYear(), today.getMonth(), 0) } },
];
}
Calendar Grid Component
"use client";
import { useRef, useCallback } from "react";
import { getDaysInMonth, getFirstDayOfMonth, isSameDay, isInRange } from "./date-utils";
import type { DateRange } from "./date-utils";
interface CalendarGridProps {
year: number;
month: number;
range: DateRange;
onSelectDate: (date: Date) => void;
minDate?: Date;
maxDate?: Date;
}
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
export function CalendarGrid({
year,
month,
range,
onSelectDate,
minDate,
maxDate,
}: CalendarGridProps) {
const gridRef = useRef<HTMLDivElement>(null);
const daysInMonth = getDaysInMonth(year, month);
const firstDay = getFirstDayOfMonth(year, month);
const isDisabled = useCallback(
(date: Date): boolean => {
if (minDate && date < minDate) return true;
if (maxDate && date > maxDate) return true;
return false;
},
[minDate, maxDate],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent, date: Date) => {
let newDate: Date | null = null;
switch (e.key) {
case "ArrowLeft":
newDate = new Date(date);
newDate.setDate(date.getDate() - 1);
break;
case "ArrowRight":
newDate = new Date(date);
newDate.setDate(date.getDate() + 1);
break;
case "ArrowUp":
newDate = new Date(date);
newDate.setDate(date.getDate() - 7);
break;
case "ArrowDown":
newDate = new Date(date);
newDate.setDate(date.getDate() + 7);
break;
case "Enter":
case " ":
e.preventDefault();
if (!isDisabled(date)) {
onSelectDate(date);
}
return;
default:
return;
}
e.preventDefault();
if (newDate && !isDisabled(newDate)) {
// Focus the button for the new date
const dayNum = newDate.getDate();
const buttons = gridRef.current?.querySelectorAll("[data-day]");
const target = Array.from(buttons || []).find(
(btn) => btn.getAttribute("data-day") === String(dayNum),
) as HTMLElement | undefined;
target?.focus();
}
},
[isDisabled, onSelectDate],
);
const days: (number | null)[] = [];
for (let i = 0; i < firstDay; i++) {
days.push(null);
}
for (let i = 1; i <= daysInMonth; i++) {
days.push(i);
}
const monthLabel = new Date(year, month).toLocaleDateString("en-US", {
month: "long",
year: "numeric",
});
return (
<div role="grid" aria-label={monthLabel} ref={gridRef}>
{/* Header row */}
<div role="row" className="grid grid-cols-7 mb-1">
{WEEKDAYS.map((day) => (
<div
key={day}
role="columnheader"
className="text-center text-xs font-medium text-muted-foreground py-1"
aria-label={day}
>
{day}
</div>
))}
</div>
{/* Day cells */}
<div role="rowgroup" className="grid grid-cols-7">
{days.map((day, idx) => {
if (!day) {
return <div key={`empty-${idx}`} role="gridcell" />;
}
const date = new Date(year, month, day);
const disabled = isDisabled(date);
const isStart = range.start ? isSameDay(date, range.start) : false;
const isEnd = range.end ? isSameDay(date, range.end) : false;
const inRange = isInRange(date, range.start, range.end);
return (
<div key={day} role="gridcell">
<button
type="button"
data-day={day}
disabled={disabled}
tabIndex={day === 1 ? 0 : -1}
aria-selected={isStart || isEnd}
aria-label={`${date.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric" })}${isStart ? ", range start" : ""}${isEnd ? ", range end" : ""}`}
onClick={() => onSelectDate(date)}
onKeyDown={(e) => handleKeyDown(e, date)}
className={`
w-full aspect-square flex items-center justify-center text-sm rounded-md
focus:outline-none focus:ring-2 focus:ring-primary
disabled:opacity-25 disabled:cursor-not-allowed
${isStart || isEnd ? "bg-primary text-primary-foreground" : ""}
${inRange && !isStart && !isEnd ? "bg-primary/10" : ""}
${!inRange && !isStart && !isEnd ? "hover:bg-muted" : ""}
`}
>
{day}
</button>
</div>
);
})}
</div>
</div>
);
}
Date Range Picker
"use client";
import { useState, useCallback, useRef, useEffect } from "react";
import { CalendarGrid } from "./CalendarGrid";
import { formatDate, getPresets } from "./date-utils";
import type { DateRange } from "./date-utils";
interface DateRangePickerProps {
value: DateRange;
onChange: (range: DateRange) => void;
minDate?: Date;
maxDate?: Date;
}
export function DateRangePicker({ value, onChange, minDate, maxDate }: DateRangePickerProps) {
const [open, setOpen] = useState(false);
const [viewDate, setViewDate] = useState(() => value.start || new Date());
const [selecting, setSelecting] = useState<"start" | "end">("start");
const containerRef = useRef<HTMLDivElement>(null);
const presets = getPresets();
// Close on click outside
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
if (open) {
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}
}, [open]);
// Close on Escape
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
if (open) {
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}
}, [open]);
const handleSelectDate = useCallback(
(date: Date) => {
if (selecting === "start") {
onChange({ start: date, end: null });
setSelecting("end");
} else {
if (value.start && date < value.start) {
onChange({ start: date, end: value.start });
} else {
onChange({ start: value.start, end: date });
}
setSelecting("start");
setOpen(false);
}
},
[selecting, value, onChange],
);
const prevMonth = () => {
setViewDate((d) => new Date(d.getFullYear(), d.getMonth() - 1, 1));
};
const nextMonth = () => {
setViewDate((d) => new Date(d.getFullYear(), d.getMonth() + 1, 1));
};
const displayText =
value.start && value.end
? `${formatDate(value.start)} - ${formatDate(value.end)}`
: value.start
? `${formatDate(value.start)} - Select end date`
: "Select date range";
const nextMonthDate = new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 1);
return (
<div ref={containerRef} className="relative inline-block">
<button
type="button"
onClick={() => setOpen(!open)}
aria-haspopup="dialog"
aria-expanded={open}
className="flex items-center gap-2 px-3 py-2 border rounded-md text-sm hover:bg-muted"
>
<span aria-hidden="true">📅</span>
<span>{displayText}</span>
</button>
{open && (
<div
role="dialog"
aria-label="Select date range"
className="absolute top-full mt-1 left-0 z-50 bg-background border rounded-lg shadow-lg p-4 flex gap-4"
>
{/* Presets */}
<div className="flex flex-col gap-1 border-r pr-4 min-w-[140px]">
<p className="text-xs font-medium text-muted-foreground mb-1">Presets</p>
{presets.map((preset) => (
<button
key={preset.label}
type="button"
onClick={() => {
onChange(preset.range);
setOpen(false);
}}
className="text-left text-sm px-2 py-1 rounded hover:bg-muted"
>
{preset.label}
</button>
))}
</div>
{/* Calendars */}
<div className="flex gap-4">
<div>
<div className="flex items-center justify-between mb-2">
<button
type="button"
onClick={prevMonth}
aria-label="Previous month"
className="p-1 rounded hover:bg-muted"
>
‹
</button>
<span className="text-sm font-medium">
{viewDate.toLocaleDateString("en-US", { month: "long", year: "numeric" })}
</span>
<span className="w-6" />
</div>
<CalendarGrid
year={viewDate.getFullYear()}
month={viewDate.getMonth()}
range={value}
onSelectDate={handleSelectDate}
minDate={minDate}
maxDate={maxDate}
/>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<span className="w-6" />
<span className="text-sm font-medium">
{nextMonthDate.toLocaleDateString("en-US", { month: "long", year: "numeric" })}
</span>
<button
type="button"
onClick={nextMonth}
aria-label="Next month"
className="p-1 rounded hover:bg-muted"
>
›
</button>
</div>
<CalendarGrid
year={nextMonthDate.getFullYear()}
month={nextMonthDate.getMonth()}
range={value}
onSelectDate={handleSelectDate}
minDate={minDate}
maxDate={maxDate}
/>
</div>
</div>
</div>
)}
</div>
);
}
Usage
"use client";
import { useState } from "react";
import { DateRangePicker } from "./DateRangePicker";
import type { DateRange } from "./date-utils";
export default function ReportsPage() {
const [range, setRange] = useState<DateRange>({
start: null,
end: null,
});
return (
<div className="p-8">
<h1 className="text-2xl font-bold mb-4">Reports</h1>
<DateRangePicker
value={range}
onChange={setRange}
maxDate={new Date()}
/>
</div>
);
}
Need Polished UI Components?
We build accessible, production-ready interfaces. Contact us to learn more.