Calendar
A Gregorian single-date or range calendar with month/year selection, disabled dates, week numbers and keyboard navigation.
Give your AI assistant this page as context.
Connect your library with MCPui/calendarUsage
Get this component from a saved library export or the connected CLI. The imports below refer to local files installed in your app.
Import styles/components.css, then styles/theme.css once. Wrap your app in ThemeScope from components/ui/utils so typography, focus and overlays share the theme. The installation guide covers runtime dependencies and React/Next.js setup.
import { Calendar } from './components/ui/calendar';
<Calendar defaultValue="2026-09-17" onValueChange={date=>console.info(date)}/>Range selection and constraints
<Calendar
mode="range"
defaultValue={{ start: "2026-10-05", end: "2026-10-09" }}
numberOfMonths={2}
locale="fr-FR"
weekStartsOn={1}
showWeekNumbers
disabledDates={["2026-10-15"]}
/>Choose the start, then the end. Reverse selections are normalized into chronological order. Use min and max for inclusive boundaries and excludeDisabled to control whether a range can cross unavailable dates. The status explains an incomplete or invalid range. Select a month or enter a year to jump directly; onMonthChange observes navigation.
Calendar renders the selection interface itself. DatePicker adds the trigger, editable date fields, shortcuts, clearing, form values and optional Apply/Cancel confirmation.
Design system
This component consumes your semantic colors, spacing, typography and shape tokens. Import the compiled styles and your theme once as described in Export.
Icons use Lucide React. Install lucide-react@1.47.0 alongside React and Base UI.
Dependencies: Button.
Behavior and accessibility
- Date-only ISO values are independent of time zones and display locale.
- Arrow keys, Home/End, PageUp/PageDown and Shift+PageUp/PageDown move through days, months and years.
- Disabled dates can be listed or supplied as a predicate; range selection can reject unavailable dates inside an interval.
- Use
numberOfMonths={2}for two months and showWeekNumbers for ISO week numbers.
Status: experimental during early access. Sample previews do not send emails, process payments or upload files to a service.
Source and props
This is the exported source, using the same implementation as the editor.
"use client";
import {
useId,
useRef,
useState,
useEffect,
useEffectEvent,
useSyncExternalStore,
type KeyboardEvent,
type ReactNode,
} from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "./button";
import { cx } from "./utils";
/** Values are Gregorian calendar dates, never timestamps or local-midnight instants. */
export type DateRange = { start: string; end: string };
export type DisabledDates = readonly string[] | ((date: string) => boolean);
export type CalendarLabels = {
previousMonth: string;
nextMonth: string;
month: string;
year: string;
week: string;
chooseDate: string;
chooseRange: string;
chooseEnd: string;
unavailableRange: string;
today: string;
yesterday: string;
last7Days: string;
last30Days: string;
thisMonth: string;
lastMonth: string;
clear: string;
apply: string;
cancel: string;
close: string;
startDate: string;
endDate: string;
invalidDate: string;
};
export function calendarLabels(
locale = "en",
overrides?: Partial<CalendarLabels>,
): CalendarLabels {
return {
...(locale.toLowerCase().startsWith("fr")
? {
previousMonth: "Mois précédent",
nextMonth: "Mois suivant",
month: "Mois",
year: "Année",
week: "Semaine",
chooseDate: "Choisir une date",
chooseRange: "Choisir une période",
chooseEnd: "Choisissez la date de fin.",
unavailableRange: "Cette période contient une date indisponible.",
today: "Aujourd’hui",
yesterday: "Hier",
last7Days: "7 derniers jours",
last30Days: "30 derniers jours",
thisMonth: "Ce mois-ci",
lastMonth: "Le mois dernier",
clear: "Effacer",
apply: "Appliquer",
cancel: "Annuler",
close: "Fermer le calendrier",
startDate: "Date de début",
endDate: "Date de fin",
invalidDate: "Choisissez une date ou une période disponible.",
}
: {
previousMonth: "Previous month",
nextMonth: "Next month",
month: "Month",
year: "Year",
week: "Week",
chooseDate: "Choose a date",
chooseRange: "Choose a date range",
chooseEnd: "Choose the end date.",
unavailableRange: "This range contains an unavailable date.",
today: "Today",
yesterday: "Yesterday",
last7Days: "Last 7 days",
last30Days: "Last 30 days",
thisMonth: "This month",
lastMonth: "Last month",
clear: "Clear",
apply: "Apply",
cancel: "Cancel",
close: "Close calendar",
startDate: "Start date",
endDate: "End date",
invalidDate: "Choose an available date or date range.",
}),
...overrides,
};
}
export function parseCalendarDate(iso: string): Date {
if (
!/^\d{4}-\d{2}-\d{2}$/.test(iso) ||
iso < "0001-01-01" ||
iso > "9999-12-31"
)
throw new Error("Expected an ISO calendar date");
const date = new Date(`${iso}T12:00:00Z`);
if (
!Number.isFinite(date.valueOf()) ||
date.toISOString().slice(0, 10) !== iso
)
throw new Error("Invalid calendar date");
return date;
}
export function calendarISO(date: Date) {
return date.toISOString().slice(0, 10);
}
export function calendarToday() {
const now = new Date();
return `${String(now.getFullYear()).padStart(4, "0")}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
}
function subscribeToCalendarDay(notify: () => void) {
const timer = setInterval(notify, 60_000);
window.addEventListener("focus", notify);
return () => {
clearInterval(timer);
window.removeEventListener("focus", notify);
};
}
const serverCalendarDay = () => "";
/** Local-day features appear after hydration, never from the server's timezone. */
export function useCalendarToday() {
return useSyncExternalStore(
subscribeToCalendarDay,
calendarToday,
serverCalendarDay,
);
}
export function addCalendarDays(value: string, amount: number) {
const date = parseCalendarDate(value);
date.setUTCDate(date.getUTCDate() + amount);
return date.getUTCFullYear() < 1
? "0001-01-01"
: date.getUTCFullYear() > 9999
? "9999-12-31"
: calendarISO(date);
}
export function startOfCalendarMonth(value: string) {
return value.slice(0, 7) + "-01";
}
export function addCalendarMonths(value: string, amount: number) {
const date = parseCalendarDate(value),
day = date.getUTCDate();
date.setUTCDate(1);
date.setUTCMonth(date.getUTCMonth() + amount);
if (date.getUTCFullYear() < 1) return "0001-01-01";
if (date.getUTCFullYear() > 9999) return "9999-12-31";
const last = new Date(date);
last.setUTCMonth(last.getUTCMonth() + 1);
last.setUTCDate(0);
date.setUTCDate(Math.min(day, last.getUTCDate()));
return calendarISO(date);
}
export function endOfCalendarMonth(value: string) {
const date = parseCalendarDate(value);
date.setUTCMonth(date.getUTCMonth() + 1, 0);
return calendarISO(date);
}
export function isoWeekNumber(value: string) {
const date = parseCalendarDate(value);
date.setUTCDate(date.getUTCDate() + 4 - (date.getUTCDay() || 7));
const first = new Date(date);
first.setUTCMonth(0, 1);
return Math.ceil(((date.valueOf() - first.valueOf()) / 86400000 + 1) / 7);
}
export function isCalendarDateDisabled(
value: string,
options: { min?: string; max?: string; disabledDates?: DisabledDates } = {},
) {
return (
value < (options.min ?? "0001-01-01") ||
value > (options.max ?? "9999-12-31") ||
(typeof options.disabledDates === "function"
? options.disabledDates(value)
: (options.disabledDates?.includes(value) ?? false))
);
}
export function isCalendarRangeAvailable(
range: DateRange,
options: {
min?: string;
max?: string;
disabledDates?: DisabledDates;
excludeDisabled?: boolean;
} = {},
) {
try {
parseCalendarDate(range.start);
parseCalendarDate(range.end);
} catch {
return false;
}
if (
range.start > range.end ||
isCalendarDateDisabled(range.start, options) ||
isCalendarDateDisabled(range.end, options)
)
return false;
if (!options.disabledDates || options.excludeDisabled === false) return true;
if (Array.isArray(options.disabledDates))
return !options.disabledDates.some(
(date) => date >= range.start && date <= range.end,
);
for (
let date = range.start;
date < range.end;
date = addCalendarDays(date, 1)
)
if (isCalendarDateDisabled(date, options)) return false;
return true;
}
export type CalendarBaseProps = {
label?: string;
min?: string;
max?: string;
locale?: string;
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
disabled?: boolean;
disabledDates?: DisabledDates;
excludeDisabled?: boolean;
startFrom?: string;
numberOfMonths?: 1 | 2;
showWeekNumbers?: boolean;
weekdayFormat?: "narrow" | "short" | "long";
monthSelection?: boolean;
labels?: Partial<CalendarLabels>;
previousMonthLabel?: string;
nextMonthLabel?: string;
className?: string;
renderDay?: (date: string) => ReactNode;
onMonthChange?: (month: string) => void;
/** Reports a range awaiting its second endpoint. */
onSelectionPendingChange?: (pending: boolean) => void;
};
export type CalendarProps = CalendarBaseProps &
(
| {
mode?: "single";
value?: string | null;
defaultValue?: string;
onValueChange?: (date: string) => void;
}
| {
mode: "range";
value?: DateRange | null;
defaultValue?: DateRange;
onValueChange?: (range: DateRange | null) => void;
}
);
export function Calendar(props: CalendarProps) {
const {
min = "0001-01-01",
max = "9999-12-31",
locale = "en",
weekStartsOn = 1,
disabled = false,
disabledDates,
excludeDisabled = true,
startFrom,
numberOfMonths = props.mode === "range" ? 2 : 1,
showWeekNumbers = false,
weekdayFormat = "short",
monthSelection = true,
renderDay,
onMonthChange,
className,
} = props;
const labels = calendarLabels(locale, props.labels),
label =
props.label ??
(props.mode === "range" ? labels.chooseRange : labels.chooseDate);
const [internal, setInternal] = useState<string | DateRange | null>(
props.defaultValue ?? null,
);
const chosen = props.value !== undefined ? props.value : internal;
const selected = typeof chosen === "string" ? chosen || null : null;
const range = chosen && typeof chosen !== "string" ? chosen : null;
parseCalendarDate(min);
parseCalendarDate(max);
if (min > max) throw new Error("Calendar min exceeds max");
if (selected) parseCalendarDate(selected);
if (range) {
parseCalendarDate(range.start);
parseCalendarDate(range.end);
}
if (startFrom) parseCalendarDate(startFrom);
const clamp = (date: string) => (date < min ? min : date > max ? max : date);
const today = useCalendarToday();
const [focused, setFocusDate] = useState<string | null>(null);
const focusDate =
focused ??
clamp(selected ?? range?.start ?? startFrom ?? (today || "1970-01-01"));
const [viewedMonth, setMonth] = useState<string | null>(null);
const month = viewedMonth ?? startOfCalendarMonth(focusDate);
const [anchor, setAnchor] = useState<string | null>(null),
[hovered, setHovered] = useState<string | null>(null),
[status, setStatus] = useState("");
const synchronize = useEffectEvent(() => {
if (!today && !selected && !range && !startFrom) return;
const next = clamp(selected ?? range?.start ?? focusDate);
setFocusDate(next);
setMonth(startOfCalendarMonth(next));
setAnchor(null);
setHovered(null);
props.onSelectionPendingChange?.(false);
});
useEffect(() => {
synchronize();
}, [selected, range?.start, range?.end, min, max, today]);
const grid = useRef<HTMLDivElement>(null),
headingId = useId();
if (!today && !selected && !range && !startFrom)
return (
<div
className={cx("cr-calendar cr-calendar-skeleton", className)}
role="status"
aria-label={label}
aria-busy="true"
>
<div className="cr-calendar-months">
{Array.from({ length: numberOfMonths }, (_, index) => (
<div className="cr-calendar-month" key={index} aria-hidden="true">
<span className="cr-calendar-skeleton-caption" />
<div className="cr-calendar-skeleton-days">
{Array.from({ length: 42 }, (_, day) => (
<span key={day} />
))}
</div>
</div>
))}
</div>
</div>
);
const months = Array.from({ length: numberOfMonths }, (_, index) =>
startOfCalendarMonth(addCalendarMonths(month, index)),
).filter((date, index, all) => all.indexOf(date) === index);
const unavailable = (date: string) =>
disabled || isCalendarDateDisabled(date, { min, max, disabledDates });
const fullDate = new Intl.DateTimeFormat(locale, {
dateStyle: "full",
timeZone: "UTC",
calendar: "gregory",
});
const monthName = new Intl.DateTimeFormat(locale, {
month: "long",
timeZone: "UTC",
calendar: "gregory",
});
const completeMonth = new Intl.DateTimeFormat(locale, {
month: "long",
year: "numeric",
timeZone: "UTC",
calendar: "gregory",
});
// Exactly one date remains in the keyboard tab order, including when the preferred date is disabled.
const visibleDates: string[] = [];
for (const current of months) {
const date = parseCalendarDate(current),
offset = (date.getUTCDay() - weekStartsOn + 7) % 7;
for (let index = 0; index < 42; index++) {
const day = new Date(date);
day.setUTCDate(1 - offset + index);
if (
day.getUTCFullYear() >= 1 &&
day.getUTCFullYear() <= 9999 &&
(months.length === 1 || day.getUTCMonth() === date.getUTCMonth())
)
visibleDates.push(calendarISO(day));
}
}
const tabDate =
visibleDates.includes(focusDate) && !unavailable(focusDate)
? focusDate
: visibleDates.find((date) => !unavailable(date));
function changeMonth(next: string, focus = false) {
const target = clamp(next),
nextMonth = startOfCalendarMonth(target);
setFocusDate(target);
setMonth(nextMonth);
onMonthChange?.(nextMonth);
if (focus)
requestAnimationFrame(() =>
grid.current
?.querySelector<HTMLButtonElement>(`[data-date="${target}"]`)
?.focus(),
);
}
function move(next: string, direction = 1) {
let target = clamp(next);
// Skip unavailable dates while bounding navigation to the configured date interval.
let searched = 0;
while (unavailable(target)) {
// Bound arbitrary predicates so a fully disabled calendar never freezes the UI.
if (++searched > 366) return;
const candidate = addCalendarDays(target, direction);
if (candidate === target || candidate < min || candidate > max) return;
target = candidate;
}
setFocusDate(target);
if (!visibleDates.includes(target)) {
setMonth(startOfCalendarMonth(target));
onMonthChange?.(startOfCalendarMonth(target));
}
requestAnimationFrame(() =>
grid.current
?.querySelector<HTMLButtonElement>(`[data-date="${target}"]`)
?.focus(),
);
}
function keyboard(event: KeyboardEvent<HTMLButtonElement>, date: string) {
let next: string | undefined,
direction = 1;
const day = (parseCalendarDate(date).getUTCDay() - weekStartsOn + 7) % 7;
if (event.key === "ArrowRight") next = addCalendarDays(date, 1);
if (event.key === "ArrowLeft") {
next = addCalendarDays(date, -1);
direction = -1;
}
if (event.key === "ArrowDown") next = addCalendarDays(date, 7);
if (event.key === "ArrowUp") {
next = addCalendarDays(date, -7);
direction = -1;
}
if (event.key === "Home") {
next = addCalendarDays(date, -day);
direction = 1;
}
if (event.key === "End") {
next = addCalendarDays(date, 6 - day);
direction = -1;
}
if (event.key === "PageUp" || event.key === "PageDown") {
direction = event.key === "PageUp" ? -1 : 1;
next = addCalendarMonths(date, direction * (event.shiftKey ? 12 : 1));
}
if (event.key === "Escape" && anchor) {
event.preventDefault();
event.stopPropagation();
setAnchor(null);
setHovered(null);
setStatus("");
props.onSelectionPendingChange?.(false);
}
if (next) {
event.preventDefault();
move(next, direction);
}
}
function choose(date: string) {
if (unavailable(date)) return;
setFocusDate(date);
setStatus("");
if (props.mode === "range") {
if (!anchor) {
setAnchor(date);
props.onSelectionPendingChange?.(true);
setHovered(null);
setStatus(labels.chooseEnd);
return;
}
const next = {
start: date < anchor ? date : anchor,
end: date < anchor ? anchor : date,
};
if (
!isCalendarRangeAvailable(next, {
min,
max,
disabledDates,
excludeDisabled,
})
) {
setStatus(labels.unavailableRange);
return;
}
if (props.value === undefined) setInternal(next);
props.onValueChange?.(next);
props.onSelectionPendingChange?.(false);
setAnchor(null);
setHovered(null);
} else {
if (props.value === undefined) setInternal(date);
props.onValueChange?.(date);
}
}
const previewEnd = hovered ?? focusDate,
preview = anchor
? {
start: anchor < previewEnd ? anchor : previewEnd,
end: anchor < previewEnd ? previewEnd : anchor,
}
: null;
const displayedRange = preview ?? range;
const minYear = Number(min.slice(0, 4)),
maxYear = Number(max.slice(0, 4));
return (
<div
className={cx("cr-calendar", className)}
data-months={months.length}
ref={grid}
>
<div className="cr-calendar-navigation">
<Button
variant="ghost"
size="sm"
aria-label={props.previousMonthLabel ?? labels.previousMonth}
disabled={disabled || month <= startOfCalendarMonth(min)}
onClick={() => changeMonth(addCalendarMonths(month, -1))}
>
<ChevronLeft size={16} aria-hidden="true" />
</Button>
<span id={headingId} className="cr-sr-only" aria-live="polite">
{months
.map((date) => completeMonth.format(parseCalendarDate(date)))
.join(" – ")}
</span>
<Button
variant="ghost"
size="sm"
aria-label={props.nextMonthLabel ?? labels.nextMonth}
disabled={disabled || months.at(-1)! >= startOfCalendarMonth(max)}
onClick={() => changeMonth(addCalendarMonths(month, 1))}
>
<ChevronRight size={16} aria-hidden="true" />
</Button>
</div>
<div className="cr-calendar-months">
{months.map((current, monthIndex) => {
const date = parseCalendarDate(current),
offset = (date.getUTCDay() - weekStartsOn + 7) % 7;
const monthId = `${headingId}-${monthIndex}`;
return (
<section className="cr-calendar-month" key={monthIndex}>
<div className="cr-calendar-caption">
<span className="cr-sr-only" id={monthId}>
{completeMonth.format(date)}
</span>
{monthSelection ? (
<>
<select
aria-label={`${labels.month}${months.length > 1 ? ` ${monthIndex + 1}` : ""}`}
value={date.getUTCMonth()}
disabled={disabled}
onChange={(event) => {
const next = new Date(date);
next.setUTCMonth(Number(event.target.value));
changeMonth(
addCalendarMonths(calendarISO(next), -monthIndex),
);
}}
>
{Array.from({ length: 12 }, (_, index) => {
const option = new Date(date);
option.setUTCMonth(index);
const text = calendarISO(option);
return (
<option
key={index}
value={index}
disabled={
endOfCalendarMonth(text) < min || text > max
}
>
{monthName.format(option)}
</option>
);
})}
</select>
<input
type="number"
inputMode="numeric"
aria-label={`${labels.year}${months.length > 1 ? ` ${monthIndex + 1}` : ""}`}
defaultValue={date.getUTCFullYear()}
key={date.getUTCFullYear()}
min={minYear}
max={maxYear}
disabled={disabled}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
event.currentTarget.blur();
}
}}
onBlur={(event) => {
const year = Number(event.currentTarget.value);
if (
!Number.isInteger(year) ||
year < minYear ||
year > maxYear
) {
event.currentTarget.value = String(
date.getUTCFullYear(),
);
return;
}
const next = new Date(date);
next.setUTCFullYear(year);
changeMonth(
addCalendarMonths(calendarISO(next), -monthIndex),
);
}}
/>
</>
) : (
<strong>{completeMonth.format(date)}</strong>
)}
</div>
<div
role="grid"
aria-label={label}
aria-describedby={monthId}
aria-multiselectable={props.mode === "range" || undefined}
>
<div
role="row"
className="cr-calendar-week"
data-week-numbers={showWeekNumbers || undefined}
>
{showWeekNumbers && (
<span role="columnheader" aria-label={labels.week}>
#
</span>
)}
{Array.from({ length: 7 }, (_, index) => {
const day = parseCalendarDate(
addCalendarDays("2024-01-07", (index + weekStartsOn) % 7),
);
return (
<span
key={index}
role="columnheader"
aria-label={new Intl.DateTimeFormat(locale, {
weekday: "long",
timeZone: "UTC",
calendar: "gregory",
}).format(day)}
>
{new Intl.DateTimeFormat(locale, {
weekday: weekdayFormat,
timeZone: "UTC",
calendar: "gregory",
}).format(day)}
</span>
);
})}
</div>
{Array.from({ length: 6 }, (_, week) => (
<div
key={week}
role="row"
className="cr-calendar-week"
data-week-numbers={showWeekNumbers || undefined}
>
{showWeekNumbers && (
<span
role="rowheader"
className="cr-calendar-week-number"
>
{isoWeekNumber(
addCalendarDays(
current,
week * 7 + ((4 - weekStartsOn + 7) % 7) - offset,
),
)}
</span>
)}
{Array.from({ length: 7 }, (_, dayIndex) => {
const day = new Date(date);
day.setUTCDate(1 - offset + week * 7 + dayIndex);
const outside = day.getUTCMonth() !== date.getUTCMonth();
if (
day.getUTCFullYear() < 1 ||
day.getUTCFullYear() > 9999 ||
(months.length > 1 && outside)
)
return <span role="gridcell" key={dayIndex} />;
const text = calendarISO(day),
blocked = unavailable(text),
inRange =
!!displayedRange &&
text >= displayedRange.start &&
text <= displayedRange.end;
const endpoint =
!!displayedRange &&
(text === displayedRange.start ||
text === displayedRange.end),
isSelected =
props.mode === "range"
? !anchor && inRange
: selected === text;
return (
<div
role="gridcell"
aria-selected={isSelected}
aria-disabled={blocked || undefined}
data-in-range={inRange || undefined}
data-range-start={
text === displayedRange?.start || undefined
}
data-range-end={
text === displayedRange?.end || undefined
}
key={dayIndex}
>
<button
type="button"
data-date={text}
data-outside={outside || undefined}
data-selected={
endpoint || selected === text || undefined
}
data-preview={(!!anchor && inRange) || undefined}
aria-label={fullDate.format(day)}
aria-current={text === today ? "date" : undefined}
aria-disabled={blocked || undefined}
disabled={blocked}
tabIndex={!blocked && text === tabDate ? 0 : -1}
onFocus={() => {
setFocusDate(text);
if (anchor) setHovered(text);
}}
onMouseEnter={() => {
if (anchor) setHovered(text);
}}
onKeyDown={(event) => keyboard(event, text)}
onClick={() => choose(text)}
>
{renderDay ? renderDay(text) : day.getUTCDate()}
</button>
</div>
);
})}
</div>
))}
</div>
</section>
);
})}
</div>
<div
className={status ? "cr-calendar-status" : "cr-sr-only"}
role="status"
aria-live="polite"
>
{status}
</div>
</div>
);
}