Calendar
A month grid that stays on the page: pick one date, or a range with RangeCalendar, and jump to any month or year from the header.
| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
"2026-11-19"- Arrows move by day and week, Page Up and Page Down by month, with Shift by year; Enter picks.
- Click the month or the year to choose from a grid; Escape goes back to the days.
- Weekends are unavailable here: crossed out and still reachable with the keys, but they cannot be picked.
Field marks
- Import
import { Calendar, RangeCalendar } from "cennge-ui";- Size
- 6.8 KB gzip for this import, its dependencies aside
- Pattern
- WAI-ARIA grid, one Tab stop for the days; keys from the APG date picker
- Keyboard
- Arrow left Arrow right Arrow up Arrow down HomeEndPageUpPageDownShift PageUpEnter Esc
- State
- Controlled with
valueandonValueChange, or uncontrolled - SSR
- Rendered on the server in the Next.js check; hydrates without a warning
- Values
- ISO dates such as
"2026-11-19"; a range is{ start, end } - Locale
- Month and weekday names from Intl, the first day of the week by region; no date library
- Tests
- Unit tests; end-to-end in Chromium, Firefox and WebKit, on desktop and phone viewports: calendar
- Source
- src/components/calendar
When to use#
Use a Calendar when the month itself is worth seeing: a release planner, a booking page, a filter that sits beside a list. It takes and returns plain ISO strings with no time zone, so a value goes through JSON, FormData and a server action unchanged, and a change to daylight saving time never moves it by a day.
The header's month and year open grids of the same size inside the calendar, so another year is two clicks away. RangeCalendar is the same grid for a start and an end.
Similar#
- DatePicker is a text field that opens this calendar in a popover: use it in forms, where the grid would take too much room.
- DateRangePicker does the same for RangeCalendar, with a field for each end.
Pick a date#
value is an ISO date, or null before anything is picked. To show it, format it in UTC: new Date("2026-11-19") is midnight in UTC, which is still November 18 in New York.
| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
No release date yet
import { useState } from "react";
import { Calendar } from "cennge-ui";
// The value has no time zone: format it in UTC, or the day can shift.
const fullDate = new Intl.DateTimeFormat("en-US", { dateStyle: "full", timeZone: "UTC" });
export default function ReleaseDate() {
const [date, setDate] = useState<string | null>(null);
return (
<div className="flex flex-col gap-3">
<Calendar aria-label="Release date" value={date} onValueChange={setDate} />
<p className="text-sm text-muted">
{date ? `payload-cache 1.4.0: ${fullDate.format(new Date(`${date}T00:00:00Z`))}` : "No release date yet"}
</p>
</div>
);
}A range over two months#
RangeCalendar takes two clicks: the first sets the start, then the band follows the pointer or the keyboard until the second sets the end, in either order. Escape in between starts over. onValueChange is called once both ends are picked. With visibleMonths above 1 the months sit side by side, wrap on narrow screens and leave out the neighbouring months' days.
| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
Pick the first and the last day
import { useState } from "react";
import { RangeCalendar, type DateRange } from "cennge-ui";
const DAY = 24 * 60 * 60 * 1000;
const utc = (iso: string) => Date.parse(`${iso}T00:00:00Z`);
const days = ({ start, end }: DateRange) => (utc(end) - utc(start)) / DAY + 1;
export default function BetaPeriod() {
const [beta, setBeta] = useState<DateRange | null>(null);
return (
<div className="flex flex-col gap-3">
<RangeCalendar
aria-label="Beta period"
visibleMonths={2}
defaultFocusedValue="2026-11-01"
value={beta}
onValueChange={setBeta}
/>
<p className="text-sm text-muted">
{beta
? `ai-niche-scout beta: ${beta.start} to ${beta.end}, ${days(beta)} days`
: "Pick the first and the last day"}
</p>
</div>
);
}Limits counted from today#
Days before minValue and after maxValue are disabled, and the keyboard stops at them. Days for which isDateUnavailable returns true stay focusable but cannot be picked, and a range cannot run across one. Only the browser knows the reader's today, so read it after mount; until then null means no limit.
| Sun | Mon | Tue | Wed | Thu | Fri | Sat |
|---|---|---|---|---|---|---|
import { useEffect, useState } from "react";
import { Calendar } from "cennge-ui";
const toIso = (date: Date) =>
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
const isWeekend = (iso: string) => {
const day = new Date(`${iso}T00:00:00Z`).getUTCDay();
return day === 0 || day === 6;
};
export default function DeployDay() {
// Today is the browser's, so it is read after mount; until then null means no limit.
const [limits, setLimits] = useState<{ min: string; max: string } | null>(null);
useEffect(() => {
const now = new Date();
const inFourWeeks = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 28);
setLimits({ min: toIso(now), max: toIso(inFourWeeks) });
}, []);
return (
<Calendar
aria-label="Deploy day"
minValue={limits?.min ?? null}
maxValue={limits?.max ?? null}
isDateUnavailable={isWeekend}
/>
);
}In another language#
Month and weekday names, the first day of the week and the digits follow the locale of CenngeProvider, or the calendar's own locale prop; firstDayOfWeek overrides the week. labels translates the names of the buttons and the month and year grids.
| Mo | Di | Mi | Do | Fr | Sa | So |
|---|---|---|---|---|---|---|
import { Calendar, CenngeProvider } from "cennge-ui";
export default function GermanCalendar() {
return (
// Month and weekday names and the first day of the week come from Intl for this locale.
<CenngeProvider locale="de-DE">
<Calendar
aria-label="Veröffentlichung"
defaultValue="2026-12-03"
labels={{ previousMonth: "Vorheriger Monat", nextMonth: "Nächster Monat", month: "Monat", year: "Jahr" }}
/>
</CenngeProvider>
);
}Server rendering#
A server renders in its own time zone, and a static page on the day it was built, so the calendar marks today only once it runs in the browser. One with a value renders that month on the server. One without a value keeps its size with an invisible month until it hydrates, then shows the reader's month.
Accessibility#
Each month is a grid named after it, and each day a button named with its full date, such as "Thursday, November 19, 2026". Only the focused day is in the tab order. A polite live region reads the month when it changes, and RangeCalendar reads the chosen range.
Props#
Calendar#
Also takes every attribute of <div>, except defaultValue, onChange, color, which mean something else here.