The foundation month grid: stable 6-row geometry, roving-tabindex keyboard navigation, direction-aware month slides, and a range mode with live hover preview.
npx shadcn@latest add @paragon/calendar"use client";
import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
/* ------------------------------------------------------------------
* Hand-rolled date math — no date libraries. All arithmetic works on
* local calendar components (year/month/day); day differences go
* through Date.UTC so DST shifts can never skew a comparison.
* ------------------------------------------------------------------ */
export const MONTH_NAMES = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
] as const;
const WEEKDAY_LABELS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"] as const;
const WEEKDAY_NAMES = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
] as const;
export function startOfDay(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
export function addDays(d: Date, n: number): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
}
/** Adds months, clamping the day-of-month to the target month's length. */
export function addMonths(d: Date, n: number): Date {
const y = d.getFullYear();
const m = d.getMonth() + n;
const last = new Date(y, m + 1, 0).getDate();
return new Date(y, m, Math.min(d.getDate(), last));
}
/** Negative / zero / positive when a is before / same day / after b. */
export function compareDay(a: Date, b: Date): number {
return (
Date.UTC(a.getFullYear(), a.getMonth(), a.getDate()) -
Date.UTC(b.getFullYear(), b.getMonth(), b.getDate())
);
}
export function sameDay(a: Date, b: Date): boolean {
return compareDay(a, b) === 0;
}
export function dateKey(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
/** "Jul 6, 2026" — deterministic, locale-independent. */
export function formatDate(d: Date): string {
return `${MONTH_NAMES[d.getMonth()].slice(0, 3)} ${d.getDate()}, ${d.getFullYear()}`;
}
function monthKey(d: Date): string {
return `${d.getFullYear()}-${d.getMonth()}`;
}
function startOfMonth(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), 1);
}
function startOfWeek(d: Date, weekStartsOn: 0 | 1): Date {
return addDays(d, -((d.getDay() - weekStartsOn + 7) % 7));
}
/**
* The 42 dates (6 stable rows x 7 columns) covering a month. A fixed
* 6-row grid means February and a 31-day month occupy identical
* heights — no layout jump while paging.
*/
export function monthGridDates(month: Date, weekStartsOn: 0 | 1): Date[] {
const first = startOfWeek(startOfMonth(month), weekStartsOn);
return Array.from({ length: 42 }, (_, i) => addDays(first, i));
}
function ariaDayLabel(d: Date): string {
return `${WEEKDAY_NAMES[d.getDay()]}, ${MONTH_NAMES[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`;
}
function clampDate(d: Date, min?: Date, max?: Date): Date {
if (min && compareDay(d, min) < 0) return startOfDay(min);
if (max && compareDay(d, max) > 0) return startOfDay(max);
return d;
}
/* ------------------------------------------------------------------ */
export interface CalendarRange {
from: Date | null;
to: Date | null;
}
export interface CalendarProps
extends Omit<React.ComponentProps<"div">, "onSelect"> {
mode?: "single" | "range";
/** Controlled selection (single mode). */
selected?: Date | null;
defaultSelected?: Date | null;
onSelect?: (date: Date) => void;
/** Controlled selection (range mode). */
range?: CalendarRange;
defaultRange?: CalendarRange;
onRangeChange?: (range: CalendarRange) => void;
/** Controlled visible month. */
month?: Date;
defaultMonth?: Date;
onMonthChange?: (month: Date) => void;
min?: Date;
max?: Date;
/** Per-date disabling, on top of min/max. */
isDateDisabled?: (date: Date) => boolean;
/** 0 = Sunday, 1 = Monday. */
weekStartsOn?: 0 | 1;
/** Reference "today". Pass a fixed date for deterministic renders. */
now?: Date;
/** Which month-nav chevrons to render (multi-pane layouts trim them). */
nav?: "both" | "prev" | "next" | "none";
/** Render outside-month cells as empty placeholders (multi-pane ranges). */
hideOutsideDays?: boolean;
/** Controlled hover date — lets multi-pane pickers share range preview. */
hoverDate?: Date | null;
onHoverDateChange?: (date: Date | null) => void;
/** Disables the month slide transition. */
static?: boolean;
}
const EASE_OUT: [number, number, number, number] = [0.22, 1, 0.36, 1];
const EASE_EXIT: [number, number, number, number] = [0.4, 0, 1, 1];
interface SlideCustom {
dir: number;
animate: boolean;
}
const gridVariants = {
enter: (c: SlideCustom) => ({
x: c.animate ? c.dir * 16 : 0,
opacity: c.animate ? 0 : 1,
}),
center: { x: 0, opacity: 1 },
exit: (c: SlideCustom) => ({
x: c.animate ? c.dir * -16 : 0,
opacity: 0,
transition: { duration: c.animate ? 0.15 : 0, ease: EASE_EXIT },
}),
};
const labelVariants = {
enter: (c: SlideCustom) => ({
x: c.animate ? c.dir * 12 : 0,
opacity: c.animate ? 0 : 1,
}),
center: { x: 0, opacity: 1 },
exit: (c: SlideCustom) => ({
x: c.animate ? c.dir * -12 : 0,
opacity: 0,
transition: { duration: c.animate ? 0.12 : 0, ease: EASE_EXIT },
}),
};
/**
* The foundation month grid: exact 7-column alignment on a stable 6-row
* body, roving-tabindex keyboard navigation (arrows, PageUp/Down, Home,
* End), direction-aware month slides that stay interruptible, and a
* range mode with live hover preview for pickers to compose.
*/
export function Calendar({
mode = "single",
selected: selectedProp,
defaultSelected = null,
onSelect,
range: rangeProp,
defaultRange = { from: null, to: null },
onRangeChange,
month: monthProp,
defaultMonth,
onMonthChange,
min,
max,
isDateDisabled,
weekStartsOn = 1,
now,
nav = "both",
hideOutsideDays = false,
hoverDate: hoverProp,
onHoverDateChange,
static: isStatic = false,
className,
"aria-label": ariaLabel = "Calendar",
...props
}: CalendarProps) {
const reduced = useReducedMotion() ?? false;
// Stable per-mount fallback; demos pass `now` so renders stay deterministic.
const [fallbackToday] = React.useState(() => startOfDay(new Date()));
const today = now ? startOfDay(now) : fallbackToday;
const [selectedState, setSelectedState] = React.useState<Date | null>(
defaultSelected,
);
const selected = selectedProp !== undefined ? selectedProp : selectedState;
const [rangeState, setRangeState] = React.useState<CalendarRange>(
defaultRange,
);
const range = rangeProp !== undefined ? rangeProp : rangeState;
const [monthState, setMonthState] = React.useState<Date>(() =>
startOfMonth(
defaultMonth ??
(mode === "range" ? (range.from ?? today) : (selected ?? today)),
),
);
const visibleMonth = startOfMonth(monthProp ?? monthState);
const mKey = monthKey(visibleMonth);
const [hoverState, setHoverState] = React.useState<Date | null>(null);
const hoverDate = hoverProp !== undefined ? hoverProp : hoverState;
const setHover = (d: Date | null) => {
setHoverState(d);
onHoverDateChange?.(d);
};
const [focusedDate, setFocusedDate] = React.useState<Date>(
() => selected ?? range.from ?? today,
);
// Direction is derived when the visible month changes — works for both
// internal navigation and externally controlled `month` updates.
const [slide, setSlide] = React.useState({ key: mKey, dir: 0 });
if (slide.key !== mKey) {
const [py, pm] = slide.key.split("-").map(Number);
const dir =
visibleMonth.getFullYear() * 12 + visibleMonth.getMonth() >
py * 12 + pm
? 1
: -1;
setSlide({ key: mKey, dir });
}
// Keyboard month changes render instantly (never animate keyboard actions).
const navSourceRef = React.useRef<"pointer" | "keyboard">("pointer");
const focusPendingRef = React.useRef<string | null>(null);
const rootRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
const key = focusPendingRef.current;
if (!key) return;
focusPendingRef.current = null;
rootRef.current
?.querySelector<HTMLButtonElement>(
`[data-month="${mKey}"] [data-date="${key}"]`,
)
?.focus();
});
const changeMonth = (next: Date, source: "pointer" | "keyboard") => {
navSourceRef.current = source;
const start = startOfMonth(next);
if (monthKey(start) === mKey) return;
setMonthState(start);
onMonthChange?.(start);
};
const isDisabled = (d: Date) =>
Boolean(
(min && compareDay(d, min) < 0) ||
(max && compareDay(d, max) > 0) ||
isDateDisabled?.(d),
);
const selectDay = (d: Date) => {
if (isDisabled(d)) return;
if (mode === "single") {
setSelectedState(d);
onSelect?.(d);
} else {
let next: CalendarRange;
if (!range.from || (range.from && range.to)) {
next = { from: d, to: null };
} else if (compareDay(d, range.from) < 0) {
next = { from: d, to: range.from };
} else {
next = { from: range.from, to: d };
}
setRangeState(next);
onRangeChange?.(next);
}
if (monthKey(d) !== mKey) changeMonth(d, "pointer");
setFocusedDate(d);
};
const focusDay = (target: Date) => {
const next = clampDate(target, min, max);
setFocusedDate(next);
focusPendingRef.current = dateKey(next);
if (monthKey(next) !== mKey) changeMonth(next, "keyboard");
};
const onDayKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>, d: Date) => {
let target: Date | null = null;
switch (e.key) {
case "ArrowLeft":
target = addDays(d, -1);
break;
case "ArrowRight":
target = addDays(d, 1);
break;
case "ArrowUp":
target = addDays(d, -7);
break;
case "ArrowDown":
target = addDays(d, 7);
break;
case "Home":
target = startOfWeek(d, weekStartsOn);
break;
case "End":
target = addDays(startOfWeek(d, weekStartsOn), 6);
break;
case "PageUp":
target = addMonths(d, e.shiftKey ? -12 : -1);
break;
case "PageDown":
target = addMonths(d, e.shiftKey ? 12 : 1);
break;
}
if (target) {
e.preventDefault();
focusDay(target);
}
};
const days = monthGridDates(visibleMonth, weekStartsOn);
const weeks = Array.from({ length: 6 }, (_, i) => days.slice(i * 7, i * 7 + 7));
const weekdays = Array.from(
{ length: 7 },
(_, i) => WEEKDAY_LABELS[(i + weekStartsOn) % 7],
);
// Band = committed range, or the live preview while picking the far end.
let bandStart: Date | null = null;
let bandEnd: Date | null = null;
if (mode === "range" && range.from) {
const other = range.to ?? hoverDate;
if (other) {
bandStart = compareDay(range.from, other) <= 0 ? range.from : other;
bandEnd = compareDay(range.from, other) <= 0 ? other : range.from;
} else {
bandStart = bandEnd = range.from;
}
}
// Roving tabindex target: the focused date if visible, else selection,
// else today, else the 1st of the visible month.
const tabbableKey = (() => {
const inGrid = (d: Date | null | undefined) =>
d && days.some((g) => sameDay(g, d)) ? dateKey(d) : null;
return (
inGrid(focusedDate) ??
inGrid(mode === "range" ? range.from : selected) ??
inGrid(today) ??
dateKey(visibleMonth)
);
})();
const prevDisabled = Boolean(min && compareDay(addDays(visibleMonth, -1), min) < 0);
const nextDisabled = Boolean(
max && compareDay(addMonths(visibleMonth, 1), max) > 0,
);
const animate = !isStatic && !reduced && navSourceRef.current === "pointer";
const custom: SlideCustom = { dir: slide.dir, animate };
const monthLabel = `${MONTH_NAMES[visibleMonth.getMonth()]} ${visibleMonth.getFullYear()}`;
const navButton = (dir: -1 | 1) => (
<button
type="button"
aria-label={dir === -1 ? "Previous month" : "Next month"}
disabled={dir === -1 ? prevDisabled : nextDisabled}
onClick={() => changeMonth(addMonths(visibleMonth, dir), "pointer")}
className="pressable flex size-7 items-center justify-center rounded-md text-muted-foreground transition-[background-color,color] duration-150 ease-out outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40"
>
{dir === -1 ? (
<ChevronLeft className="size-4" aria-hidden />
) : (
<ChevronRight className="size-4" aria-hidden />
)}
</button>
);
return (
<div
ref={rootRef}
className={cn("w-fit select-none", className)}
{...props}
>
<div className="flex h-8 items-center justify-between gap-2 px-1">
<span className="relative flex-1 overflow-hidden">
<span aria-live="polite" className="sr-only">
{monthLabel}
</span>
<AnimatePresence mode="popLayout" initial={false} custom={custom}>
<motion.span
key={mKey}
custom={custom}
variants={labelVariants}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: 0.18, ease: EASE_OUT }}
className="block text-sm font-medium text-foreground"
aria-hidden
>
{monthLabel}
</motion.span>
</AnimatePresence>
</span>
{nav !== "none" && (
<span className="flex shrink-0 items-center gap-0.5">
{(nav === "both" || nav === "prev") && navButton(-1)}
{(nav === "both" || nav === "next") && navButton(1)}
</span>
)}
</div>
<div role="grid" aria-label={`${ariaLabel}, ${monthLabel}`}>
<div role="row" className="mt-1 grid grid-cols-7">
{weekdays.map((w, i) => (
<span
key={w}
role="columnheader"
aria-label={WEEKDAY_NAMES[(i + weekStartsOn) % 7]}
className="flex h-8 w-9 items-center justify-center text-xs font-medium text-muted-foreground"
>
{w}
</span>
))}
</div>
<div className="relative h-[216px] overflow-hidden">
<AnimatePresence mode="popLayout" initial={false} custom={custom}>
<motion.div
key={mKey}
data-month={mKey}
custom={custom}
variants={gridVariants}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: 0.18, ease: EASE_OUT }}
role="rowgroup"
onPointerLeave={() => setHover(null)}
>
{weeks.map((week, wi) => (
<div key={wi} role="row" className="grid grid-cols-7">
{week.map((d) => {
const outside = monthKey(d) !== mKey;
if (outside && hideOutsideDays) {
return (
<span
key={dateKey(d)}
role="gridcell"
aria-hidden
className="size-9"
/>
);
}
const key = dateKey(d);
const disabled = isDisabled(d);
const isToday = sameDay(d, today);
const isSelected =
mode === "single"
? Boolean(selected && sameDay(d, selected))
: Boolean(
(range.from && sameDay(d, range.from)) ||
(range.to && sameDay(d, range.to)),
);
const inBand = Boolean(
bandStart &&
bandEnd &&
compareDay(d, bandStart) >= 0 &&
compareDay(d, bandEnd) <= 0 &&
!sameDay(bandStart, bandEnd),
);
const isBandStart = Boolean(
inBand && bandStart && sameDay(d, bandStart),
);
const isBandEnd = Boolean(
inBand && bandEnd && sameDay(d, bandEnd),
);
return (
<button
key={key}
type="button"
role="gridcell"
data-date={key}
tabIndex={key === tabbableKey ? 0 : -1}
aria-label={ariaDayLabel(d)}
aria-selected={isSelected || undefined}
aria-disabled={disabled || undefined}
aria-current={isToday ? "date" : undefined}
onKeyDown={(e) => onDayKeyDown(e, d)}
onClick={() => selectDay(d)}
onPointerEnter={() => setHover(d)}
onFocus={() => {
setFocusedDate(d);
if (mode === "range") setHover(d);
}}
className={cn(
"group relative size-9 rounded-lg text-[13px] tabular-nums outline-none focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-ring",
disabled
? "cursor-default text-muted-foreground/40"
: outside
? "text-muted-foreground/50"
: "text-foreground",
)}
>
{/* Range band — square so adjacent cells read as one bar. */}
{inBand && !disabled && (
<span
aria-hidden
className={cn(
"absolute inset-y-0.5 bg-accent",
isBandStart
? "right-0 left-1/2"
: isBandEnd
? "right-1/2 left-0"
: "inset-x-0",
)}
/>
)}
{/* Today ring, beneath the selection fill. */}
{isToday && (
<span
aria-hidden
className="absolute inset-0.5 rounded-[7px] ring-1 ring-ring/60 ring-inset"
/>
)}
{/* Selection fill grows in over 150ms; the transition
retargets mid-flight when selection moves. */}
<span
aria-hidden
className={cn(
"absolute inset-0.5 rounded-[7px] bg-primary transition-[scale,opacity] duration-150 ease-out motion-reduce:scale-100",
isSelected ? "scale-100 opacity-100" : "scale-75 opacity-0",
)}
/>
{/* Hover wash (kept under text, above band). */}
{!disabled && !isSelected && (
<span
aria-hidden
className="absolute inset-0.5 rounded-[7px] bg-accent opacity-0 transition-opacity duration-100 ease-out group-hover:opacity-100 group-focus-visible:opacity-100"
/>
)}
<span
className={cn(
"relative z-10 transition-colors duration-150 ease-out",
isSelected && "font-medium text-primary-foreground",
)}
>
{d.getDate()}
</span>
</button>
);
})}
</div>
))}
</motion.div>
</AnimatePresence>
</div>
</div>
</div>
);
}