Calendar
A calendar panel component built on the native Temporal API, supporting controlled selection, disabled dates and parts-based assembly
Installation
[!TIP] The component uses the browser-native
Temporal.PlainDateAPI. No date library is bundled and no polyfill is provided — make sure the target environment supports the Temporal API (Chrome 139+, or a polyfill installed by yourself).
Usage
Basic Usage
The visible month is fully controlled: hold it in state and update it via onVisibleMonthChange. The selection supports both controlled (value + onChange) and uncontrolled (defaultValue) modes.
The panel switches between three views: days, months and years. In the header the year and the month are two independent title buttons — click the year to open the year panel, click the month to open the month panel. Both panels are 3 × 4 grids: the month panel lists the 12 months of the visible year, the year panel lists a 12-year window centered on the visible year. Picking a year drills down into the month panel; picking a month returns to the day panel. The previous / next buttons step by one month on the day panel, one year on the month panel and a 12-year page on the year panel.
Keyboard navigation: the grid has a single tab stop (the selected date, or day 1 when nothing is selected), and ArrowRight / ArrowLeft / ArrowDown / ArrowUp move the cursor by one day or one week — only focus moves, the value does not change until Enter / Space confirms. Disabled days are skipped; when the target date is outside the current grid, the visible month switches automatically and the target date is focused. The month and year panels follow the same pattern with one cell / one row steps. ArrowDown on any header control (previous / next, year / month title) jumps straight into the active view's grid, so the header never traps the focus.
Selected: -
"use client";import { useState } from "react";import { Calendar } from "@/ui";export default function Demo() { const [visibleMonth, setVisibleMonth] = useState(() => Temporal.Now.plainDateISO()); const [selectedDate, setSelectedDate] = useState<Temporal.PlainDate | null>(null); return ( <div className="flex flex-col gap-3"> <Calendar visibleMonth={visibleMonth} onVisibleMonthChange={setVisibleMonth} value={selectedDate} onChange={setSelectedDate} firstDayOfWeek={1} /> <p className="text-sm text-muted-foreground"> Selected: {selectedDate ? selectedDate.toString() : "-"} </p> </div> );}Localizing the Month Labels
Month names come from the mutable static Calendar.calendarMonthLabels (English abbreviations by default). Override it once at your app's entry point and every calendar — including the one inside DatePicker — picks it up:
"use client";
import { useEffect, useState } from "react";
import { Calendar } from "@/ui";
const labels = [
"一月", "二月", "三月", "四月", "五月", "六月",
"七月", "八月", "九月", "十月", "十一月", "十二月",
];
export default function CalendarI18nDemo() {
const [ready, setReady] = useState(false);
useEffect(() => {
const previous = Calendar.calendarMonthLabels;
Calendar.calendarMonthLabels = labels;
setReady(true);
return () => {
Calendar.calendarMonthLabels = previous;
};
}, []);
if (!ready) return null;
return <CalendarI18nCalendar />;
}
function CalendarI18nCalendar() {
const [visibleMonth, setVisibleMonth] = useState(() => Temporal.Now.plainDateISO());
const [selectedDate, setSelectedDate] = useState<Temporal.PlainDate | null>(null);
return (
<Calendar
visibleMonth={visibleMonth}
onVisibleMonthChange={setVisibleMonth}
value={selectedDate}
onChange={setSelectedDate}
firstDayOfWeek={1}
/>
);
}
Disabled Dates
Return true from isDateDisabled to disable specific dates. Disabled cells are rendered as disabled buttons and never fire onChange.
Past dates and weekends are disabled
"use client";import { useState } from "react";import { Calendar } from "@/ui";export default function Demo() { const [visibleMonth, setVisibleMonth] = useState(() => Temporal.Now.plainDateISO()); const [selectedDate, setSelectedDate] = useState<Temporal.PlainDate | null>(null); const isDateDisabled = (date: Temporal.PlainDate) => { const today = Temporal.Now.plainDateISO(); return Temporal.PlainDate.compare(date, today) < 0 || date.dayOfWeek > 5; }; return ( <div className="flex flex-col gap-3"> <Calendar visibleMonth={visibleMonth} onVisibleMonthChange={setVisibleMonth} value={selectedDate} onChange={setSelectedDate} isDateDisabled={isDateDisabled} /> <p className="text-sm text-muted-foreground"> Past dates and weekends are disabled </p> </div> );}Parts Assembly
All parts are exported. Compose CalendarRoot, CalendarHeader, CalendarNavButton, CalendarTitleButton and the grids manually to build a fully customized panel — the demo below manages the view, the weeks array and both states itself.
"use client";
import { useState } from "react";
import {
type CalendarView,
CalendarGrid,
CalendarHeader,
CalendarMonthGrid,
CalendarNavButton,
CalendarRoot,
CalendarTitleButton,
CalendarYearGrid,
calendarMonthLabels,
} from "@/ui";
export default function Demo() {
const [view, setView] = useState<CalendarView>("days");
const [visibleMonth, setVisibleMonth] = useState(() => Temporal.Now.plainDateISO());
const [selectedDate, setSelectedDate] = useState<Temporal.PlainDate | null>(() =>
Temporal.Now.plainDateISO(),
);
const firstOfMonth = visibleMonth.with({ day: 1 });
const offset = firstOfMonth.dayOfWeek % 7;
const start = firstOfMonth.subtract({ days: offset });
const weekCount = Math.ceil((offset + visibleMonth.daysInMonth) / 7);
const weeks = Array.from({ length: weekCount }, (_, weekIndex) =>
Array.from({ length: 7 }, (_, dayIndex) => start.add({ days: weekIndex * 7 + dayIndex })),
);
const handleSelect = (date: Temporal.PlainDate) => {
if (date.year !== visibleMonth.year || date.month !== visibleMonth.month) return;
setSelectedDate(date);
};
const handleMonthSelect = (month: Temporal.PlainDate) => {
setVisibleMonth(month);
setView("days");
};
const handleYearSelect = (year: Temporal.PlainDate) => {
setVisibleMonth(visibleMonth.with({ year: year.year, day: 1 }));
setView("months");
};
const handlePrevious = () => {
if (view === "days") setVisibleMonth(visibleMonth.subtract({ months: 1 }));
else if (view === "months") setVisibleMonth(visibleMonth.subtract({ years: 1 }));
else setVisibleMonth(visibleMonth.subtract({ years: 12 }));
};
const handleNext = () => {
if (view === "days") setVisibleMonth(visibleMonth.add({ months: 1 }));
else if (view === "months") setVisibleMonth(visibleMonth.add({ years: 1 }));
else setVisibleMonth(visibleMonth.add({ years: 12 }));
};
const navUnit = view === "days" ? "month" : view === "months" ? "year" : "years";
return (
<CalendarRoot className="rounded-md border-2 border-primary p-4">
<CalendarHeader>
<CalendarNavButton
direction="previous"
label={`Previous ${navUnit}`}
onClick={handlePrevious}
/>
<div className="flex flex-1 items-center justify-center gap-1">
<CalendarTitleButton
data-active={view === "years" || undefined}
onClick={() => setView("years")}
>
{visibleMonth.year}
</CalendarTitleButton>
<CalendarTitleButton
data-active={view === "months" || undefined}
onClick={() => setView("months")}
>
{calendarMonthLabels[visibleMonth.month - 1]}
</CalendarTitleButton>
</div>
<CalendarNavButton direction="next" label={`Next ${navUnit}`} onClick={handleNext} />
</CalendarHeader>
{view === "days" && (
<CalendarGrid
weeks={weeks}
visibleMonth={visibleMonth}
value={selectedDate}
onSelect={handleSelect}
firstDayOfWeek={0}
/>
)}
{view === "months" && (
<CalendarMonthGrid
visibleMonth={visibleMonth}
value={selectedDate}
onSelect={handleMonthSelect}
/>
)}
{view === "years" && (
<CalendarYearGrid
visibleMonth={visibleMonth}
value={selectedDate}
onSelect={handleYearSelect}
/>
)}
</CalendarRoot>
);
}
API Reference
High-level Components
Ready-to-use composite component.
Calendar
| Prop | Type | Default | Description |
|---|---|---|---|
visibleMonth | Temporal.PlainDate | - | Required. The month panel currently displayed |
value | Temporal.PlainDate | null | - | Controlled selected date |
defaultValue | Temporal.PlainDate | null | - | Default selected date (uncontrolled) |
view | "days" | "months" | "years" | - | Controlled active panel |
defaultView | "days" | "months" | "years" | "days" | Initial panel (uncontrolled) |
onChange | (date: Temporal.PlainDate) => void | - | Fired when clicking a valid date inside the visible month |
onVisibleMonthChange | (month: Temporal.PlainDate) => void | - | Fired when the visible month changes (previous / next, panel pick, keyboard navigation) |
onViewChange | (view: "days" | "months" | "years") => void | - | Fired when the active panel changes |
isDateDisabled | (date: Temporal.PlainDate) => boolean | - | Return true to disable the date |
firstDayOfWeek | 0 | 1 | 0 | 0 starts the week on Sunday, 1 starts it on Monday |
className | ClassNameValue | - | Custom class names, applied to the root |
Temporal.PlainDate is immutable — all month arithmetic is performed with add / subtract / with, never by mutating an existing object.
Composable Components
CalendarRoot
The panel shell: border, background and vertical spacing. Extends all native div props.
| Prop | Type | Default | Description |
|---|---|---|---|
className | ClassNameValue | - | Custom class names |
...props | React.ComponentProps<"div"> | - | Native div props |
CalendarHeader
Header shell: a flex row for the navigation buttons and the title buttons. Pure layout — renders children between the edges, leaving the composition to you.
| Prop | Type | Default | Description |
|---|---|---|---|
className | ClassNameValue | - | Custom class names |
...props | React.ComponentProps<"header"> | - | Native header props |
CalendarNavButton
Chevron navigation button for the header. direction picks the icon; pass children to replace it with a custom icon.
| Prop | Type | Default | Description |
|---|---|---|---|
direction | "previous" | "next" | - | Which chevron icon to render |
label | string | "Previous" / "Next" | Accessible name (aria-label) |
className | ClassNameValue | - | Custom class names |
...props | React.ComponentProps<"button"> | - | Native button props (except type and aria-label) |
CalendarTitleButton
Text button for the header title area — typically rendered twice as the year and the month. Setting the data-active attribute highlights it as the current panel.
| Prop | Type | Default | Description |
|---|---|---|---|
className | ClassNameValue | - | Custom class names |
...props | React.ComponentProps<"button"> | - | Native button props (except type), such as onClick |
CalendarMonthGrid
3 × 4 month panel listing the 12 months of the visible year. Cells carry a data-month attribute for in-grid lookup; arrow keys move by one month or one row, and leaving the shown year calls onNavigate.
| Prop | Type | Default | Description |
|---|---|---|---|
visibleMonth | Temporal.PlainDate | - | Source of the shown year; its month is the fallback tab stop |
value | Temporal.PlainDate | null | - | Selected date, highlights the cell of the same month |
isMonthDisabled | (month: Temporal.PlainDate) => boolean | - | Receives the first day of each month; return true to disable it |
onSelect | (month: Temporal.PlainDate) => void | - | Fired with the first day of the picked month |
onNavigate | (month: Temporal.PlainDate) => void | - | Fired when arrowing out of the shown year with the target month |
className | ClassNameValue | - | Custom class names |
...props | React.ComponentProps<"div"> | - | Native div props |
CalendarYearGrid
3 × 4 year panel showing a 12-year window centered on the visible year. Cells carry a data-year attribute for in-grid lookup; arrow keys move by one year or one row, and leaving the window calls onNavigate.
| Prop | Type | Default | Description |
|---|---|---|---|
visibleMonth | Temporal.PlainDate | - | Centers the 12-year window; its year is the fallback tab stop |
value | Temporal.PlainDate | null | - | Selected date, highlights the cell of the same year |
isYearDisabled | (year: Temporal.PlainDate) => boolean | - | Receives January 1st of each year; return true to disable it |
onSelect | (year: Temporal.PlainDate) => void | - | Fired with January 1st of the picked year |
onNavigate | (year: Temporal.PlainDate) => void | - | Fired when arrowing out of the window with the target year |
className | ClassNameValue | - | Custom class names |
...props | React.ComponentProps<"div"> | - | Native div props |
CalendarGrid
Weekday header row plus the weeks body. Expects a two-dimensional weeks array of Temporal.PlainDate (including padding dates from the previous and next months).
| Prop | Type | Default | Description |
|---|---|---|---|
weeks | Temporal.PlainDate[][] | - | Two-dimensional weeks array, 7 dates per row |
visibleMonth | Temporal.PlainDate | - | Used to detect out-of-month cells |
value | Temporal.PlainDate | null | - | Selected date, drives the highlighted cell |
isDateDisabled | (date: Temporal.PlainDate) => boolean | - | Return true to disable the date |
onSelect | (date: Temporal.PlainDate) => void | - | Fired on cell click; the composite filters out-of-month dates |
onNavigate | (date: Temporal.PlainDate) => void | - | Fired when arrowing to a date not present in the grid |
firstDayOfWeek | 0 | 1 | 0 | Weekday header order |
className | ClassNameValue | - | Custom class names |
...props | React.ComponentProps<"div"> | - | Native div props |
CalendarGridRow
A single week row. Usually rendered by CalendarGrid, but can be composed manually.
| Prop | Type | Default | Description |
|---|---|---|---|
week | Temporal.PlainDate[] | - | The 7 dates of this week |
visibleMonth | Temporal.PlainDate | - | Used to detect out-of-month cells |
value | Temporal.PlainDate | null | - | Selected date |
isDateDisabled | (date: Temporal.PlainDate) => boolean | - | Return true to disable the date |
onSelect | (date: Temporal.PlainDate) => void | - | Fired on cell click |
onNavigate | (date: Temporal.PlainDate) => void | - | Fired when arrowing to a date not present in the grid |
tabStopDate | string | - | ISO date string of the single tab stop in the grid; omit to keep all cells natively tabbable |
className | ClassNameValue | - | Custom class names |
...props | React.ComponentProps<"div"> | - | Native div props |
CalendarGridCell
A single date cell rendered as a button. aria-selected drives the selected styles, data-outside-month fades out-of-month cells. Arrow keys move the cursor to another day (Enter / Space confirms); each cell carries a data-date attribute for in-grid lookup.
| Prop | Type | Default | Description |
|---|---|---|---|
date | Temporal.PlainDate | - | The date this cell renders (shows date.day) |
outsideMonth | boolean | false | Grays out cells not belonging to the visible month |
selected | boolean | false | Highlights the cell as selected |
tabStop | boolean | - | Roving tabindex: true makes the cell the only tab stop; omit to keep native tab behavior |
onNavigate | (date: Temporal.PlainDate) => void | - | Fired when arrowing to a date not present in the grid |
className | ClassNameValue | - | Custom class names |
...props | React.ComponentProps<"button"> | - | Native button props (except type), such as disabled |