Skip to content
cennge-ui
GitHubv0.3.1, changelog

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.

November 2026
SunMonTueWedThuFriSat
cennge-ui 0.4.0Ships Thursday, November 19"2026-11-19"
  1. Arrows move by day and week, Page Up and Page Down by month, with Shift by year; Enter picks.
  2. Click the month or the year to choose from a grid; Escape goes back to the days.
  3. Weekends are unavailable here: crossed out and still reachable with the keys, but they cannot be picked.
Live: click it, use the keys.

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 value and onValueChange, 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

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.

No release date yet

basic.tsx
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.

November 2026 – December 2026
SunMonTueWedThuFriSat
SunMonTueWedThuFriSat

Pick the first and the last day

range.tsx
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.

from-today.tsx
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.

Dezember 2026
MoDiMiDoFrSaSo
locale.tsx
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#

PropTypeDefault
value
string | nullSelected date as an ISO string, e.g. "2026-10-19".
–
defaultValue
string | null
–
onValueChange
(value: string) => void
–
minValue
string | nullEarliest date that can be chosen, ISO. Also limits the year picker. null: no limit, e.g. while today is not known yet.
–
maxValue
string | nullLatest date that can be chosen, ISO. null: no limit, e.g. while today is not known yet.
–
isDateUnavailable
(date: string) => booleanReturn true for dates that cannot be chosen, e.g. fully booked days. They stay focusable.
–
defaultFocusedValue
stringMonth to show while nothing is selected, as any ISO date in it.
today
firstDayOfWeek
number0 = Sunday … 6 = Saturday.
the locale's
locale
stringBCP 47 tag for month and weekday names.
the provider's locale, else "en-US"
showOutsideDays
booleanDays of the neighbouring months in the first and last week.
true
labels
Partial<CalendarLabels>Accessible names of the controls, for translation.
–
isDisabled
boolean
–
isReadOnly
booleanCan be browsed but not changed.
–
autoFocus
booleanFocus the selected day (or today) on mount.
–
color
"default""primary""secondary""success""warning""danger""info"Colour of the selected day.
"primary"
size
"sm""md""lg"
"md"
classNames
Partial<Record<CalendarSlots, string>>
–

Also takes every attribute of <div>, except defaultValue, onChange, color, which mean something else here.

RangeCalendar#

PropTypeDefault
value
DateRange | null
–
defaultValue
DateRange | null
–
onValueChange
(range: DateRange) => voidCalled once both ends are chosen.
–
visibleMonths
numberMonths side by side; they wrap on narrow screens.
1
autoFocus
booleanFocus the selected day (or today) on mount.
–
color
"default""primary""secondary""success""warning""danger""info"Colour of the selected day.
"primary"
size
"sm""md""lg"
"md"
isDisabled
boolean
–
minValue
string | nullEarliest date that can be chosen, ISO. Also limits the year picker. null: no limit, e.g. while today is not known yet.
–
maxValue
string | nullLatest date that can be chosen, ISO. null: no limit, e.g. while today is not known yet.
–
isDateUnavailable
(date: string) => booleanReturn true for dates that cannot be chosen, e.g. fully booked days. They stay focusable.
–
defaultFocusedValue
stringMonth to show while nothing is selected, as any ISO date in it.
today
firstDayOfWeek
number0 = Sunday … 6 = Saturday.
the locale's
locale
stringBCP 47 tag for month and weekday names.
the provider's locale, else "en-US"
showOutsideDays
booleanDays of the neighbouring months in the first and last week.
true
labels
Partial<CalendarLabels>Accessible names of the controls, for translation.
–
isReadOnly
booleanCan be browsed but not changed.
–
classNames
Partial<Record<CalendarSlots, string>>
–