Calendar
A standalone visual calendar for date selection. Supports single, range, and multiple selection modes with keyboard navigation and disabled date support.
AccessibleDark ModeKeyboard Nav3 Modes
Install
$
npx react-principles add calendar01
Theme Preview
Range selection preview across both themes — rendered with forced styling so the comparison is accurate regardless of the current app theme.
Light
chevron_left
April 2026chevron_right
Su
Mo
Tu
We
Th
Fr
Sa
13
14
15
16
17
18
19
Range selection preview
Dark
chevron_left
April 2026chevron_right
Su
Mo
Tu
We
Th
Fr
Sa
13
14
15
16
17
18
19
Range selection preview
02
Live Demo
Explore all variants and interactive states in Storybook.
Open Storybookopen_in_newMode
Options
May 2026
Su
Mo
Tu
We
Th
Fr
Sa
Selected: None
03
Code Snippet
src/ui/Calendar.tsx
import { Calendar } from "@/ui/Calendar"; // Single selection <Calendar mode="single" selected={selectedDate} onSelect={setSelectedDate} /> // Range selection <Calendar mode="range" selected={dateRange} onSelect={setDateRange} /> // Multiple selection <Calendar mode="multiple" selected={dates} onSelect={setDates} /> // With disabled dates (weekends) <Calendar disabled={(date) => date.getDay() === 0 || date.getDay() === 6} /> // With selectable bounds <Calendar fromDate={new Date(2026, 3, 1)} toDate={new Date(2026, 3, 30)} />
04
Copy-Paste (Single File)
Calendar.tsx
"use client"; import { forwardRef, useCallback, useEffect, useMemo, useRef, useState, type HTMLAttributes, } from "react"; import { cn } from "@/lib/utils"; // ─── Types ──────────────────────────────────────────────────────────────────── export type CalendarMode = "single" | "range" | "multiple"; export interface DateRange { from: Date; to?: Date; } export type CalendarSelected = Date | DateRange | Date[]; type DisabledDate = Date | ((date: Date) => boolean); export interface CalendarProps extends Omit<HTMLAttributes<HTMLDivElement>, "onSelect"> { mode?: CalendarMode; selected?: CalendarSelected; defaultSelected?: CalendarSelected; onSelect?: (date: CalendarSelected) => void; defaultMonth?: Date; disabled?: DisabledDate | DisabledDate[]; fromDate?: Date; toDate?: Date; showOutsideDays?: boolean; } // ─── Date Utilities ─────────────────────────────────────────────────────────── function normalizeDate(d: Date): Date { const result = new Date(d); result.setHours(0, 0, 0, 0); return result; } function isSameDay(a: Date, b: Date): boolean { return ( a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate() ); } function isBefore(a: Date, b: Date): boolean { return normalizeDate(a).getTime() < normalizeDate(b).getTime(); } function isAfter(a: Date, b: Date): boolean { return isBefore(b, a); } function addMonths(date: Date, months: number): Date { const result = new Date(date); result.setMonth(result.getMonth() + months); return normalizeDate(result); } function startOfMonth(date: Date): Date { return new Date(date.getFullYear(), date.getMonth(), 1); } function endOfMonth(date: Date): Date { return new Date(date.getFullYear(), date.getMonth() + 1, 0); } // ─── Grid Types ─────────────────────────────────────────────────────────────── interface CalendarDay { date: Date; dayOfMonth: number; isCurrentMonth: boolean; isSelected: boolean; isToday: boolean; isDisabled: boolean; isInRange: boolean; isRangeStart: boolean; isRangeEnd: boolean; } type RangeState = "none" | "start" | "end"; const WEEKDAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"] as const; const MONTH_NAMES = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", ] as const; // ─── Grid Builder ───────────────────────────────────────────────────────────── function buildCalendarGrid( viewMonth: Date, mode: CalendarMode, selected: CalendarSelected | undefined, disabledDates: DisabledDate[], fromDate: Date | undefined, toDate: Date | undefined, showOutsideDays: boolean, ): CalendarDay[] { const today = normalizeDate(new Date()); const first = startOfMonth(viewMonth); const startDow = first.getDay(); const gridStart = new Date(first); gridStart.setDate(gridStart.getDate() - startDow); const days: CalendarDay[] = []; const totalCells = 42; for (let i = 0; i < totalCells; i++) { const date = new Date(gridStart); date.setDate(date.getDate() + i); const normalized = normalizeDate(date); const isCurrentMonth = date.getMonth() === viewMonth.getMonth() && date.getFullYear() === viewMonth.getFullYear(); if (!showOutsideDays && !isCurrentMonth) continue; let isSelected = false; let isInRange = false; let isRangeStart = false; let isRangeEnd = false; if (mode === "single" && selected instanceof Date) { isSelected = isSameDay(normalized, selected); } else if ( mode === "range" && selected !== undefined && selected !== null && typeof selected === "object" && "from" in selected ) { const range = selected; const from = normalizeDate(range.from); const to = range.to ? normalizeDate(range.to) : undefined; isRangeStart = isSameDay(normalized, from); isRangeEnd = to !== undefined && isSameDay(normalized, to); isSelected = isRangeStart || isRangeEnd; if (to !== undefined) { isInRange = isAfter(normalized, from) && isBefore(normalized, to); } } else if (mode === "multiple" && Array.isArray(selected)) { isSelected = selected.some((d) => isSameDay(normalized, d)); } let isDisabled = false; if (fromDate && isBefore(normalized, fromDate)) isDisabled = true; if (toDate && isAfter(normalized, toDate)) isDisabled = true; for (const dd of disabledDates) { if (typeof dd === "function") { if (dd(normalized)) { isDisabled = true; break; } } else if (isSameDay(normalized, dd)) { isDisabled = true; break; } } days.push({ date: normalized, dayOfMonth: normalized.getDate(), isCurrentMonth, isSelected, isToday: isSameDay(normalized, today), isDisabled, isInRange, isRangeStart, isRangeEnd, }); } return days; } // ─── Selection Handlers ─────────────────────────────────────────────────────── function handleSingleClick(date: Date): Date { return date; } function handleMultipleClick(date: Date, current: CalendarSelected | undefined): Date[] { const arr = Array.isArray(current) ? [...current] : []; const idx = arr.findIndex((d) => isSameDay(d, date)); if (idx >= 0) { arr.splice(idx, 1); } else { arr.push(date); arr.sort((a, b) => a.getTime() - b.getTime()); } return arr; } function handleRangeClick( date: Date, current: CalendarSelected | undefined, rangeState: RangeState, ): { next: DateRange; newState: RangeState } { if (rangeState === "none" || rangeState === "end") { return { next: { from: date }, newState: "start" }; } const range = current as DateRange; const from = normalizeDate(range.from); const to = normalizeDate(date); if (isBefore(to, from)) { return { next: { from: to, to: from }, newState: "end" }; } return { next: { from, to }, newState: "end" }; } // ─── Keyboard Navigation ────────────────────────────────────────────────────── function moveFocus( e: React.KeyboardEvent, focusedDate: Date, viewMonth: Date, setFocusedDate: (d: Date) => void, setViewMonth: (d: Date) => void, handleSelectDay: (day: CalendarDay) => void, days: CalendarDay[], ): void { let nextDate = new Date(focusedDate); let handled = true; switch (e.key) { case "ArrowRight": nextDate.setDate(nextDate.getDate() + 1); break; case "ArrowLeft": nextDate.setDate(nextDate.getDate() - 1); break; case "ArrowDown": nextDate.setDate(nextDate.getDate() + 7); break; case "ArrowUp": nextDate.setDate(nextDate.getDate() - 7); break; case "PageDown": nextDate = addMonths(nextDate, 1); setViewMonth(addMonths(viewMonth, 1)); break; case "PageUp": nextDate = addMonths(nextDate, -1); setViewMonth(addMonths(viewMonth, -1)); break; case "Home": nextDate = startOfMonth(viewMonth); break; case "End": nextDate = endOfMonth(viewMonth); break; case "Enter": case " ": { e.preventDefault(); const day = days.find((d) => isSameDay(d.date, focusedDate)); if (day && !day.isDisabled) handleSelectDay(day); return; } default: handled = false; } if (!handled) return; e.preventDefault(); nextDate = normalizeDate(nextDate); setFocusedDate(nextDate); if ( nextDate.getMonth() !== viewMonth.getMonth() || nextDate.getFullYear() !== viewMonth.getFullYear() ) { setViewMonth(startOfMonth(nextDate)); } const key = `${nextDate.getFullYear()}-${nextDate.getMonth()}-${nextDate.getDate()}`; const el = document.querySelector<HTMLElement>(`[data-calendar-day="${key}"]`); el?.focus(); } // ─── Calendar Component ─────────────────────────────────────────────────────── export const Calendar = forwardRef<HTMLDivElement, CalendarProps>( function Calendar( { mode = "single", selected: controlledSelected, defaultSelected, onSelect, defaultMonth, disabled, fromDate, toDate, showOutsideDays = true, className, ...props }, ref, ) { const [viewMonth, setViewMonth] = useState<Date>( () => (defaultMonth ? normalizeDate(defaultMonth) : normalizeDate(new Date())), ); const [internalSelected, setInternalSelected] = useState< CalendarSelected | undefined >(defaultSelected); const [focusedDate, setFocusedDate] = useState<Date>( () => (defaultMonth ? normalizeDate(defaultMonth) : normalizeDate(new Date())), ); const [rangeState, setRangeState] = useState<RangeState>("none"); const isControlled = controlledSelected !== undefined; const currentSelected = isControlled ? controlledSelected : internalSelected; const disabledDates: DisabledDate[] = useMemo(() => { if (!disabled) return []; return Array.isArray(disabled) ? disabled : [disabled]; }, [disabled]); const days = useMemo( () => buildCalendarGrid( viewMonth, mode, currentSelected, disabledDates, fromDate, toDate, showOutsideDays, ), [viewMonth, mode, currentSelected, disabledDates, fromDate, toDate, showOutsideDays], ); const prevSelectedRef = useRef(currentSelected); useEffect(() => { if (prevSelectedRef.current === currentSelected) return; prevSelectedRef.current = currentSelected; if (mode === "single" && currentSelected instanceof Date) { setFocusedDate(normalizeDate(currentSelected)); } }, [currentSelected, mode]); const goToPreviousMonth = useCallback(() => { setViewMonth((prev) => addMonths(prev, -1)); }, []); const goToNextMonth = useCallback(() => { setViewMonth((prev) => addMonths(prev, 1)); }, []); const handleDayClick = useCallback( (day: CalendarDay) => { if (day.isDisabled) return; let next: CalendarSelected; if (mode === "single") { next = handleSingleClick(day.date); } else if (mode === "multiple") { next = handleMultipleClick(day.date, currentSelected); } else { const result = handleRangeClick(day.date, currentSelected, rangeState); setRangeState(result.newState); next = result.next; } if (!isControlled) setInternalSelected(next); onSelect?.(next); setFocusedDate(day.date); }, [mode, currentSelected, rangeState, isControlled, onSelect], ); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { moveFocus(e, focusedDate, viewMonth, setFocusedDate, setViewMonth, handleDayClick, days); }, [focusedDate, viewMonth, handleDayClick, days], ); const monthLabel = `${MONTH_NAMES[viewMonth.getMonth()]} ${viewMonth.getFullYear()}`; return ( <div ref={ref} className={cn( "inline-flex flex-col rounded-xl border border-slate-200 bg-white p-4", "dark:border-[#1f2937] dark:bg-[#161b22]", className, )} {...props} > {/* Header */} <div className="mb-4 flex items-center justify-between"> <button type="button" onClick={goToPreviousMonth} className="rounded-lg p-1.5 text-slate-500 transition-colors hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-[#1f2937]" aria-label="Previous month" > <span className="material-symbols-outlined text-[20px]">chevron_left</span> </button> <span className="text-sm font-semibold text-slate-900 dark:text-white"> {monthLabel} </span> <button type="button" onClick={goToNextMonth} className="rounded-lg p-1.5 text-slate-500 transition-colors hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-[#1f2937]" aria-label="Next month" > <span className="material-symbols-outlined text-[20px]">chevron_right</span> </button> </div> {/* Weekday Headers */} <div className="mb-2 grid grid-cols-7"> {WEEKDAYS.map((day) => ( <div key={day} className="flex h-8 items-center justify-center text-xs font-medium text-slate-500 dark:text-slate-400" > {day} </div> ))} </div> {/* Day Grid */} <div role="grid" className="grid grid-cols-7" onKeyDown={handleKeyDown}> {days.map((day) => ( <button key={day.date.toISOString()} type="button" role="gridcell" data-calendar-day={`${day.date.getFullYear()}-${day.date.getMonth()}-${day.date.getDate()}`} tabIndex={isSameDay(day.date, focusedDate) ? 0 : -1} disabled={day.isDisabled} onClick={() => handleDayClick(day)} aria-selected={day.isSelected || day.isInRange || undefined} className={cn( "relative flex h-9 w-full items-center justify-center rounded-lg text-sm font-medium transition-colors", "focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-primary/40", !day.isCurrentMonth && !day.isSelected && "text-slate-300 dark:text-slate-600", day.isCurrentMonth && !day.isSelected && !day.isInRange && !day.isDisabled && "text-slate-700 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-[#1f2937]", day.isToday && !day.isSelected && "font-bold text-primary", day.isSelected && "bg-primary text-white hover:bg-primary/90", day.isInRange && !day.isSelected && "bg-primary/10 dark:bg-primary/20", day.isDisabled && "cursor-not-allowed text-slate-300 opacity-50 dark:text-slate-600", )} > {day.dayOfMonth} {day.isToday && !day.isSelected && ( <span className="absolute bottom-1 left-1/2 h-0.5 w-1 -translate-x-1/2 rounded-full bg-primary" /> )} </button> ))} </div> </div> ); }, );
05
Props
| Prop | Type | Default | Description |
|---|---|---|---|
mode | "single" | "range" | "multiple" | "single" | Controls the selection behavior. |
selected | Date | DateRange | Date[] | — | Controlled selected date(s). Use with onSelect. |
defaultSelected | Date | DateRange | Date[] | — | Initial selection for uncontrolled usage. |
onSelect | (date: CalendarSelected) => void | — | Callback fired when a date is selected. |
defaultMonth | Date | Today | The month to display on initial render. |
disabled | Date | ((date: Date) => boolean) | Array | — | Dates to disable. Can be a date, a function, or an array of either. |
fromDate | Date | — | Earliest selectable date. |
toDate | Date | — | Latest selectable date. |
showOutsideDays | boolean | true | Whether to show days from adjacent months. |
className | string | — | Additional classes merged into the root container. |