UI Library
Date range picker
Two months side by side with a preset rail (today, last week, this year…), a live hover preview while picking the end date, event dots, and editable start and end fields. Nothing applies until Apply; Cancel reverts. Arrow keys move through the days.
Formsdatecalendarrangepickerkeyboard
Preview
apps/site/blocks/components/DateRangePicker.tsx
'use client';
import { useMemo, useState, useSyncExternalStore, type KeyboardEvent } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
export interface DateRange {
from: Date;
to: Date;
}
type PresetId =
| 'today'
| 'yesterday'
| 'this-week'
| 'last-week'
| 'this-month'
| 'last-month'
| 'this-year'
| 'last-year'
| 'all';
const PRESETS: { id: PresetId; label: string }[] = [
{ id: 'today', label: 'Today' },
{ id: 'yesterday', label: 'Yesterday' },
{ id: 'this-week', label: 'This week' },
{ id: 'last-week', label: 'Last week' },
{ id: 'this-month', label: 'This month' },
{ id: 'last-month', label: 'Last month' },
{ id: 'this-year', label: 'This year' },
{ id: 'last-year', label: 'Last year' },
{ id: 'all', label: 'All time' },
];
const WEEKDAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
// ── Date helpers ─────────────────────────────────────────────────────────────
// Every date is a local midnight. No library: the arithmetic a range picker
// needs is small, and `new Date(y, m, d)` normalises overflow for us.
const day = (y: number, m: number, d: number) => new Date(y, m, d);
const addDays = (d: Date, n: number) => day(d.getFullYear(), d.getMonth(), d.getDate() + n);
const addMonths = (d: Date, n: number) => day(d.getFullYear(), d.getMonth() + n, 1);
const startOfMonth = (d: Date) => day(d.getFullYear(), d.getMonth(), 1);
const endOfMonth = (d: Date) => day(d.getFullYear(), d.getMonth() + 1, 0);
/** Monday on or before `d`. */
const startOfWeek = (d: Date) => addDays(d, -((d.getDay() + 6) % 7));
const sameDay = (a: Date, b: Date) => a.getTime() === b.getTime();
const sameMonth = (a: Date, b: Date) =>
a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth();
const monthsBetween = (a: Date, b: Date) =>
(b.getFullYear() - a.getFullYear()) * 12 + b.getMonth() - a.getMonth();
const keyOf = (d: Date) => `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
const ordered = (a: Date, b: Date): DateRange => (a <= b ? { from: a, to: b } : { from: b, to: a });
const fmtDate = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
const fmtMonth = new Intl.DateTimeFormat('en-US', { month: 'long', year: 'numeric' });
const fmtLong = new Intl.DateTimeFormat('en-US', { dateStyle: 'full' });
function presetRange(id: PresetId, today: Date, earliest: Date): DateRange {
const y = today.getFullYear();
switch (id) {
case 'today':
return { from: today, to: today };
case 'yesterday': {
const d = addDays(today, -1);
return { from: d, to: d };
}
case 'this-week':
return { from: startOfWeek(today), to: today };
case 'last-week': {
const from = addDays(startOfWeek(today), -7);
return { from, to: addDays(from, 6) };
}
case 'this-month':
return { from: startOfMonth(today), to: today };
case 'last-month': {
const from = addMonths(today, -1);
return { from, to: endOfMonth(from) };
}
case 'this-year':
return { from: day(y, 0, 1), to: today };
case 'last-year':
return { from: day(y - 1, 0, 1), to: day(y - 1, 11, 31) };
case 'all':
return { from: earliest, to: today };
}
}
/** Left-hand month to show so the range is in view. */
function viewFor(range: DateRange): Date {
return monthsBetween(range.from, range.to) <= 1
? startOfMonth(range.from)
: addMonths(range.to, -1);
}
// "Today" differs between the static build and the visitor's browser, so it is
// read on the client only. The server snapshot is null and the calendar renders
// once hydrated, which avoids a hydration mismatch on the date grid.
const noopSubscribe = () => () => {};
const todayKey = () => {
const n = new Date();
return `${n.getFullYear()}-${n.getMonth()}-${n.getDate()}`;
};
function useToday(override?: Date): Date | null {
const key = useSyncExternalStore(noopSubscribe, todayKey, () => null);
return useMemo(() => {
if (override) return day(override.getFullYear(), override.getMonth(), override.getDate());
if (!key) return null;
const [y, m, d] = key.split('-').map(Number);
return day(y, m, d);
}, [key, override]);
}
// ── Component ────────────────────────────────────────────────────────────────
export function DateRangePicker({
defaultValue,
today: todayProp,
earliest = day(2020, 0, 1),
marked,
onApply,
onCancel,
}: {
/** Initial range. Defaults to the 14 days ending today. */
defaultValue?: DateRange;
/** Pin "today" (tests, screenshots). Defaults to the visitor's local date. */
today?: Date;
/** Start of "All time". */
earliest?: Date;
/** Days that get a dot under the number — events, releases, deploys. */
marked?: Date[];
onApply?: (range: DateRange) => void;
onCancel?: () => void;
}) {
const today = useToday(todayProp);
if (!today) {
// Same footprint as the real thing, so the page does not jump on hydrate.
return (
<div
aria-hidden
className="h-[372px] w-full max-w-[760px] rounded-[12px] border border-border bg-popover shadow-lg"
/>
);
}
return (
<Picker
today={today}
earliest={earliest}
initial={defaultValue ?? { from: addDays(today, -13), to: today }}
marked={marked ?? demoMarks(today)}
onApply={onApply}
onCancel={onCancel}
/>
);
}
/** A few dots around today so the preview has something to show. */
function demoMarks(today: Date): Date[] {
return [-40, -27, -20, -9, -2, 4, 13, 22].map((n) => addDays(today, n));
}
function Picker({
today,
earliest,
initial,
marked,
onApply,
onCancel,
}: {
today: Date;
earliest: Date;
initial: DateRange;
marked: Date[];
onApply?: (range: DateRange) => void;
onCancel?: () => void;
}) {
const [applied, setApplied] = useState<DateRange>(initial);
const [draft, setDraft] = useState<DateRange>(initial);
// Set while the first click is waiting for the second.
const [anchor, setAnchor] = useState<Date | null>(null);
const [hover, setHover] = useState<Date | null>(null);
const [view, setView] = useState<Date>(() => viewFor(initial));
const [focused, setFocused] = useState<Date>(initial.to);
const markedKeys = useMemo(() => new Set(marked.map(keyOf)), [marked]);
// While picking the end, the hovered day previews the range.
const shown = anchor ? ordered(anchor, hover ?? anchor) : draft;
const activePreset = anchor
? null
: (PRESETS.find(({ id }) => {
const r = presetRange(id, today, earliest);
return sameDay(r.from, draft.from) && sameDay(r.to, draft.to);
})?.id ?? null);
function select(range: DateRange) {
setDraft(range);
setAnchor(null);
setHover(null);
setView(viewFor(range));
setFocused(range.to);
}
function pick(d: Date) {
setFocused(d);
if (anchor) {
setDraft(ordered(anchor, d));
setAnchor(null);
setHover(null);
} else {
setDraft({ from: d, to: d });
setAnchor(d);
}
}
// Roving focus: one tabbable day per picker, arrows move it, and moving past
// the visible months pages the view along with it.
function moveFocus(next: Date) {
setFocused(next);
if (anchor) setHover(next);
const offset = monthsBetween(view, next);
if (offset < 0) setView(startOfMonth(next));
else if (offset > 1) setView(addMonths(next, -1));
requestAnimationFrame(() => {
document.querySelector<HTMLButtonElement>(`[data-day="${keyOf(next)}"]`)?.focus();
});
}
function onGridKeyDown(event: KeyboardEvent<HTMLDivElement>) {
const steps: Record<string, () => Date> = {
ArrowLeft: () => addDays(focused, -1),
ArrowRight: () => addDays(focused, 1),
ArrowUp: () => addDays(focused, -7),
ArrowDown: () => addDays(focused, 7),
Home: () => startOfWeek(focused),
End: () => addDays(startOfWeek(focused), 6),
PageUp: () => day(focused.getFullYear(), focused.getMonth() - 1, focused.getDate()),
PageDown: () => day(focused.getFullYear(), focused.getMonth() + 1, focused.getDate()),
};
const step = steps[event.key];
if (step) {
event.preventDefault();
moveFocus(step());
} else if (event.key === 'Escape' && anchor) {
setAnchor(null);
setHover(null);
setDraft(applied);
}
}
const months = [view, addMonths(view, 1)];
return (
<div className="flex w-full max-w-[760px] flex-col overflow-hidden rounded-[12px] border border-border bg-popover text-sm text-popover-foreground shadow-lg sm:flex-row">
{/* Preset rail — a scrolling row on phones, a column beside the months above. */}
<nav
aria-label="Date presets"
className="flex shrink-0 gap-1 overflow-x-auto border-b border-border p-2 sm:w-36 sm:flex-col sm:justify-between sm:overflow-visible sm:border-b-0 sm:border-r sm:px-0"
>
<ul className="flex gap-1 sm:flex-col sm:gap-0.5">
{PRESETS.map(({ id, label }) => (
<li key={id}>
<PresetButton
active={activePreset === id}
onClick={() => select(presetRange(id, today, earliest))}
>
{label}
</PresetButton>
</li>
))}
</ul>
<PresetButton
active={activePreset === null}
onClick={() => document.getElementById('drp-from')?.focus()}
>
Custom
</PresetButton>
</nav>
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex divide-x divide-border" onKeyDown={onGridKeyDown}>
{months.map((month, i) => (
<Month
key={keyOf(month)}
month={month}
className={i === 1 ? 'hidden md:block' : undefined}
showPrev={i === 0}
// On phones only the first month shows, so it carries both arrows.
nextClassName={i === 0 ? 'md:invisible' : undefined}
onPrev={() => setView(addMonths(view, -1))}
onNext={() => setView(addMonths(view, 1))}
range={shown}
today={today}
focused={focused}
markedKeys={markedKeys}
onPick={pick}
onHover={anchor ? setHover : undefined}
/>
))}
</div>
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border p-3">
<div className="flex items-center gap-2">
<DateField
key={`from-${keyOf(draft.from)}`}
id="drp-from"
label="Start date"
value={draft.from}
onCommit={(d) => select(ordered(d, draft.to))}
/>
<span aria-hidden className="text-muted-foreground">
–
</span>
<DateField
key={`to-${keyOf(draft.to)}`}
id="drp-to"
label="End date"
value={draft.to}
onCommit={(d) => select(ordered(draft.from, d))}
/>
</div>
<div className="ml-auto flex gap-2">
<button
type="button"
onClick={() => {
select(applied);
onCancel?.();
}}
className="inline-flex h-8 items-center rounded-[6px] border border-border bg-background px-3 font-medium transition-colors hover:bg-accent"
>
Cancel
</button>
<button
type="button"
disabled={anchor !== null}
onClick={() => {
setApplied(draft);
onApply?.(draft);
}}
className="inline-flex h-8 items-center rounded-[6px] bg-primary px-3 font-medium text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-50"
>
Apply
</button>
</div>
</div>
</div>
</div>
);
}
function PresetButton({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: string;
}) {
return (
<button
type="button"
aria-pressed={active}
onClick={onClick}
className={`relative w-full whitespace-nowrap rounded-[6px] px-3 py-1.5 text-left transition-colors sm:rounded-none sm:px-4 ${
active
? 'bg-accent font-medium text-foreground'
: 'text-muted-foreground hover:bg-accent/60 hover:text-foreground'
}`}
>
{active ? (
<span aria-hidden className="absolute inset-y-0 left-0 hidden w-0.5 bg-primary sm:block" />
) : null}
{children}
</button>
);
}
function Month({
month,
className,
showPrev,
nextClassName,
onPrev,
onNext,
range,
today,
focused,
markedKeys,
onPick,
onHover,
}: {
month: Date;
className?: string;
showPrev: boolean;
nextClassName?: string;
onPrev: () => void;
onNext: () => void;
range: DateRange;
today: Date;
focused: Date;
markedKeys: Set<string>;
onPick: (d: Date) => void;
onHover?: (d: Date) => void;
}) {
const first = startOfWeek(month);
// Always six weeks, so the two months stay the same height side by side.
const days = Array.from({ length: 42 }, (_, i) => addDays(first, i));
const last = endOfMonth(month);
const title = fmtMonth.format(month);
return (
<div className={`flex-1 p-3 ${className ?? ''}`}>
<div className="mb-2 flex items-center justify-between">
<NavButton label="Previous month" onClick={onPrev} className={showPrev ? '' : 'invisible'}>
<ChevronLeft className="h-4 w-4" />
</NavButton>
<h3 className="font-semibold" aria-live="polite">
{title}
</h3>
<NavButton label="Next month" onClick={onNext} className={nextClassName}>
<ChevronRight className="h-4 w-4" />
</NavButton>
</div>
<div role="group" aria-label={title} className="grid grid-cols-7 gap-y-1 tabular-nums">
{WEEKDAYS.map((w) => (
<div
key={w}
className="flex h-8 items-center justify-center text-xs font-medium text-muted-foreground"
>
{w}
</div>
))}
{days.map((d, i) => {
const inMonth = sameMonth(d, month);
const isFrom = sameDay(d, range.from);
const isTo = sameDay(d, range.to);
const isEnd = inMonth && (isFrom || isTo);
// The band is drawn only on in-month days so a range is not painted
// twice where the two grids overlap.
const inBand = inMonth && d >= range.from && d <= range.to;
const col = i % 7;
const roundL = col === 0 || isFrom || d.getDate() === 1;
const roundR = col === 6 || isTo || sameDay(d, last);
const isToday = sameDay(d, today);
const hasMark = markedKeys.has(keyOf(d));
return (
<div
key={keyOf(d)}
className={`flex justify-center ${
inBand
? `bg-accent ${roundL ? 'rounded-l-[8px]' : ''} ${roundR ? 'rounded-r-[8px]' : ''}`
: ''
}`}
>
<button
type="button"
data-day={inMonth ? keyOf(d) : undefined}
tabIndex={inMonth && sameDay(d, focused) ? 0 : -1}
aria-label={fmtLong.format(d)}
aria-pressed={isEnd}
aria-current={isToday ? 'date' : undefined}
onClick={() => onPick(d)}
onMouseEnter={onHover ? () => onHover(d) : undefined}
onFocus={onHover ? () => onHover(d) : undefined}
className={`relative flex h-8 w-full max-w-9 items-center justify-center rounded-[8px] outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring ${
isEnd
? 'bg-primary font-semibold text-primary-foreground'
: inMonth
? `hover:bg-muted ${isToday ? 'font-semibold text-primary' : ''}`
: 'text-muted-foreground/60 hover:bg-muted'
}`}
>
{d.getDate()}
{hasMark ? (
<span
aria-hidden
className={`absolute bottom-0.5 left-1/2 h-1 w-1 -translate-x-1/2 rounded-full ${
isEnd ? 'bg-primary-foreground' : 'bg-primary'
} ${inMonth ? '' : 'opacity-50'}`}
/>
) : null}
</button>
</div>
);
})}
</div>
</div>
);
}
function NavButton({
label,
onClick,
className,
children,
}: {
label: string;
onClick: () => void;
className?: string;
children: React.ReactNode;
}) {
return (
<button
type="button"
aria-label={label}
onClick={onClick}
className={`inline-flex h-7 w-7 items-center justify-center rounded-[6px] border border-border text-muted-foreground transition-colors hover:bg-accent hover:text-foreground ${className ?? ''}`}
>
{children}
</button>
);
}
/**
* A typed date. Keyed by its value upstream, so a range change from the grid
* resets the text; a typo reverts on blur instead of clearing the range.
*/
function DateField({
id,
label,
value,
onCommit,
}: {
id: string;
label: string;
value: Date;
onCommit: (d: Date) => void;
}) {
const [text, setText] = useState(() => fmtDate.format(value));
function commit() {
const parsed = new Date(text);
if (Number.isNaN(parsed.getTime())) {
setText(fmtDate.format(value));
return;
}
const d = day(parsed.getFullYear(), parsed.getMonth(), parsed.getDate());
if (sameDay(d, value)) setText(fmtDate.format(value));
else onCommit(d);
}
return (
<input
id={id}
aria-label={label}
value={text}
onChange={(e) => setText(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') commit();
}}
className="h-8 w-[7.5rem] rounded-[6px] border border-border bg-background px-2.5 tabular-nums outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
);
}
Dependencies
lucide-react— npm package
Colours come from CSS variables (--background, --primary, --border) using the shadcn/ui token names, so the block adopts your palette rather than importing ours.