Calendar
SourceA composable date grid. Calendar.Root owns the engine — the focused day, the visible month, the selection — and the parts compose freely around it: pagers and a formatted heading over a keyboard-operable month table. The display calendar follows the ambient locale (a Persian locale renders jalali automatically, Persian digits included), values stay immutable CalendarDate objects, and the same surface drops into a page, a popover, a dialog, or a filter panel.
Basic
The canonical composition: a Calendar.Header holding the Previous/Next pagers around the formatted Heading, over the Calendar.Grid month table. Click a day to select it; the value is uncontrolled here. The heading doubles as the zoom control — click it to open the month picker, and again for the year picker; picking a year, then a month, then a day drills back to any date.
| S | M | T | W | T | F | S |
|---|---|---|---|---|---|---|
"use client";
import { Calendar } from "@stridge/noctis/calendar";
export default function CalendarBasic() {
return (
<Calendar.Root>
<Calendar.Header>
<Calendar.Previous />
<Calendar.Heading />
<Calendar.Next />
</Calendar.Header>
<Calendar.Grid />
</Calendar.Root>
);
}
Controlled
Control the value with value + onValueChange. The committed value is always emitted in the input value's calendar system (gregorian when there is none) no matter what system is displayed, and value.toString() gives the calendar-native ISO string — ready for a URL or an API. The second onValueChange argument carries the covered period as a { start, end } range for consumers that filter by span.
| S | M | T | W | T | F | S |
|---|---|---|---|---|---|---|
"use client";
import { Calendar } from "@stridge/noctis/calendar";
import { type DateValue, getLocalTimeZone, today } from "@stridge/noctis/i18n";
import { useState } from "react";
export default function CalendarControlled() {
const [value, setValue] = useState<DateValue | null>(today(getLocalTimeZone()));
return (
<div className="flex flex-col items-center gap-3">
<Calendar.Root value={value} onValueChange={setValue}>
<Calendar.Header>
<Calendar.Previous />
<Calendar.Heading />
<Calendar.Next />
</Calendar.Header>
<Calendar.Grid />
</Calendar.Root>
<output className="text-small text-muted">{value ? value.toString() : "—"}</output>
</div>
);
}
Calendar systems
The display system resolves from the ambient locale — switch the docs to Farsi and every calendar on this page renders jalali with no code change. The calendar prop pins a specific Unicode system ("persian", "islamic-umalqura", "hebrew", …) regardless of locale. Weekday order, week start, month names, and digits all come from the locale, so a pinned system still reads naturally in any language.
| S | M | T | W | T | F | S |
|---|---|---|---|---|---|---|
"use client";
import { Calendar } from "@stridge/noctis/calendar";
export default function CalendarJalali() {
// Under a Persian locale the system is picked up automatically; the explicit prop pins it here
// so the example reads jalali on the English docs too.
return (
<Calendar.Root calendar="persian">
<Calendar.Header>
<Calendar.Previous />
<Calendar.Heading />
<Calendar.Next />
</Calendar.Header>
<Calendar.Grid />
</Calendar.Root>
);
}
A switcher is plain composition — the calendar prop is just state:
| S | M | T | W | T | F | S |
|---|---|---|---|---|---|---|
"use client";
import { Calendar } from "@stridge/noctis/calendar";
import { Select } from "@stridge/noctis/select";
import { useState } from "react";
const SYSTEMS = [
{ value: "gregory", label: "Gregorian" },
{ value: "persian", label: "Persian (Jalali)" },
{ value: "islamic-umalqura", label: "Islamic (Umm al-Qura)" },
{ value: "hebrew", label: "Hebrew" },
] as const;
type SystemId = (typeof SYSTEMS)[number]["value"];
/** The display system is a prop — the same parts, the same value model, any Unicode calendar. */
export default function CalendarSwitcher() {
const [system, setSystem] = useState<SystemId>("gregory");
return (
<div className="flex flex-col items-center gap-4">
<Select.Root items={SYSTEMS} value={system} onValueChange={(value) => setSystem(value as SystemId)}>
<Select.Trigger aria-label="Calendar system" className="w-56">
<Select.Value />
</Select.Trigger>
<Select.Popup>
{SYSTEMS.map((item) => (
<Select.Item key={item.value} value={item.value}>
{item.label}
</Select.Item>
))}
</Select.Popup>
</Select.Root>
<Calendar.Root calendar={system}>
<Calendar.Header>
<Calendar.Previous />
<Calendar.Heading />
<Calendar.Next />
</Calendar.Header>
<Calendar.Grid />
</Calendar.Root>
</div>
);
}
Bounds and unavailable dates
min/max clamp the selectable window — out-of-range days disable, and the pagers disable when a whole page would fall outside. isDateUnavailable marks individual days unselectable (struck through, skipped on commit) while keeping them focusable, so keyboard users can still traverse them.
| S | M | T | W | T | F | S |
|---|---|---|---|---|---|---|
"use client";
import { Calendar } from "@stridge/noctis/calendar";
import { getLocalTimeZone, isWeekend, today, useLocale } from "@stridge/noctis/i18n";
export default function CalendarBounds() {
const { locale } = useLocale();
const now = today(getLocalTimeZone());
return (
<Calendar.Root
min={now.subtract({ days: 7 })}
max={now.add({ months: 1 })}
isDateUnavailable={(date) => isWeekend(date, locale)}
>
<Calendar.Header>
<Calendar.Previous />
<Calendar.Heading />
<Calendar.Next />
</Calendar.Header>
<Calendar.Grid />
</Calendar.Root>
);
}
Coarse pickers
minView sets the selection floor — the granularity where a pick commits instead of drilling deeper. minView="quarter" turns the same parts into a quarter picker: the grid becomes a scrolling timeline of year sections, the committed value is the period's first day, and the full period arrives as details.range in the onValueChange callback. month, halfyear, and year work the same way; maxView caps how far the heading can zoom out. Every view change cross-fades subtly in the direction of travel — set data-instant on the root to suppress it (reduced-motion users never see it).
"use client";
import { Calendar } from "@stridge/noctis/calendar";
import { type DateValue } from "@stridge/noctis/i18n";
import { useState } from "react";
export default function CalendarQuarterPicker() {
const [value, setValue] = useState<DateValue | null>(null);
return (
<div className="flex flex-col items-center gap-3">
<Calendar.Root minView="quarter" value={value} onValueChange={setValue}>
<Calendar.Header>
<Calendar.Previous />
<Calendar.Heading />
<Calendar.Next />
</Calendar.Header>
<Calendar.Grid />
</Calendar.Root>
<output className="text-small text-muted">{value ? value.toString() : "Pick a quarter"}</output>
</div>
);
}
Granularity tabs
The full period-picker pattern: a segmented strip choosing the granularity, each tab its own picker with that floor. The view model is also fully controllable (view + onViewChange) when a shell needs to own it.
"use client";
import { Calendar, type CalendarView } from "@stridge/noctis/calendar";
import { Tabs } from "@stridge/noctis/tabs";
import { useState } from "react";
const GRANULARITIES: ReadonlyArray<[CalendarView, string]> = [
["day", "Date"],
["month", "Month"],
["quarter", "Quarter"],
["halfyear", "Half-year"],
["year", "Year"],
];
export default function CalendarGranularityTabs() {
const [granularity, setGranularity] = useState<CalendarView>("quarter");
return (
<div className="flex flex-col items-center gap-4">
<Tabs.Root
variant="chip"
size="sm"
value={granularity}
onValueChange={(value) => setGranularity(value as CalendarView)}
>
<Tabs.List>
{GRANULARITIES.map(([value, label]) => (
<Tabs.Tab key={value} value={value}>
{label}
</Tabs.Tab>
))}
<Tabs.Indicator />
</Tabs.List>
</Tabs.Root>
{/* Remount per granularity: each tab is its own picker with that selection floor. */}
<Calendar.Root key={granularity} minView={granularity}>
<Calendar.Header>
<Calendar.Previous />
<Calendar.Heading />
<Calendar.Next />
</Calendar.Header>
<Calendar.Grid />
</Calendar.Root>
</div>
);
}
Range selection
RangeCalendar.Root hosts the exact same parts over a { start, end } value. The first pick anchors, the highlight follows the keyboard cursor and the pointer while the second pick is pending, and Escape cancels the anchor. The committed endpoints arrive in the input value's calendar system, and when dates are unavailable a pending selection clamps to the contiguous run around its anchor.
| S | M | T | W | T | F | S |
|---|---|---|---|---|---|---|
"use client";
import { Calendar, type CalendarRangeValue, RangeCalendar } from "@stridge/noctis/calendar";
import { useState } from "react";
export default function CalendarRange() {
const [value, setValue] = useState<CalendarRangeValue | null>(null);
return (
<div className="flex flex-col items-center gap-3">
<RangeCalendar.Root value={value} onValueChange={setValue}>
<Calendar.Header>
<Calendar.Previous />
<Calendar.Heading />
<Calendar.Next />
</Calendar.Header>
<Calendar.Grid />
</RangeCalendar.Root>
<output className="text-small text-muted">
{value ? `${value.start.toString()} → ${value.end.toString()}` : "Pick a start, then an end — Escape cancels"}
</output>
</div>
);
}
Presets
Presets are plain composition on the controlled value — no dedicated part, just buttons that set the range.
| S | M | T | W | T | F | S |
|---|---|---|---|---|---|---|
"use client";
import { Button } from "@stridge/noctis/button";
import { Calendar, type CalendarRangeValue, RangeCalendar } from "@stridge/noctis/calendar";
import { endOfMonth, getLocalTimeZone, startOfMonth, toCalendarDate, today } from "@stridge/noctis/i18n";
import { useState } from "react";
const PRESETS: ReadonlyArray<[string, () => CalendarRangeValue]> = [
["Today", () => ({ start: today(getLocalTimeZone()), end: today(getLocalTimeZone()) })],
["Last 7 days", () => ({ start: today(getLocalTimeZone()).subtract({ days: 6 }), end: today(getLocalTimeZone()) })],
["Last 30 days", () => ({ start: today(getLocalTimeZone()).subtract({ days: 29 }), end: today(getLocalTimeZone()) })],
[
"This month",
() => {
const now = today(getLocalTimeZone());
return { start: startOfMonth(now), end: endOfMonth(now) };
},
],
];
/** Presets are plain composition: a controlled range plus buttons that set it. */
export default function CalendarPresets() {
const [value, setValue] = useState<CalendarRangeValue | null>(null);
// Bumped per preset click: remounting navigates the window to the fresh range's START, so a
// span reaching into the previous month never reads as starting mid-window.
const [presetNonce, setPresetNonce] = useState(0);
return (
<div className="flex items-start gap-6">
<div className="flex flex-col items-stretch gap-1">
{PRESETS.map(([label, range]) => (
<Button
key={label}
variant="ghost"
size="sm"
className="justify-start"
onClick={() => {
setValue(range());
setPresetNonce((nonce) => nonce + 1);
}}
>
{label}
</Button>
))}
</div>
<RangeCalendar.Root
key={presetNonce}
defaultFocusedDate={value ? toCalendarDate(value.start) : undefined}
value={value}
onValueChange={setValue}
>
<Calendar.Header>
<Calendar.Previous />
<Calendar.Heading />
<Calendar.Next />
</Calendar.Header>
<Calendar.Grid />
</RangeCalendar.Root>
</div>
);
}
Two months, one range
visibleMonths widens the window; compose one Calendar.Grid per month with offset, title each with Calendar.Heading offset, and keep ONE nav cluster — Calendar.Today (jump-to-today) plus the pagers — at the row's end. The band runs seamlessly across the grids, paging moves the whole spread (pageBehavior="single" steps one month instead), and weekdayStyle="twoletter" gives the two-letter weekday labels ("Su Mo Tu" — locales without abbreviations keep their natural narrow forms). The example collapses to a single month below its breakpoint.
August 2026
September 2026
| Su | Mo | Tu | We | Th | Fr | Sa |
|---|---|---|---|---|---|---|
| Su | Mo | Tu | We | Th | Fr | Sa |
|---|---|---|---|---|---|---|
"use client";
import { Calendar, RangeCalendar } from "@stridge/noctis/calendar";
import { useEffect, useState } from "react";
/** The two-month range layout: per-grid month titles, one nav cluster at the row's end. */
export default function CalendarRangeTwoUp() {
// Responsive 1↔2 months: below the breakpoint the window narrows to one month and the nav
// cluster moves next to the only heading.
const [months, setMonths] = useState(2);
useEffect(() => {
const query = window.matchMedia("(min-width: 600px)");
const update = () => setMonths(query.matches ? 2 : 1);
update();
query.addEventListener("change", update);
return () => query.removeEventListener("change", update);
}, []);
const navCluster = (
<>
<Calendar.Today />
<Calendar.Previous />
<Calendar.Next />
</>
);
return (
// maxView="day" keeps the per-grid titles static, like the reference layout — the
// granularity strip pattern (see "In a dialog") is the drill affordance here instead.
<RangeCalendar.Root key={months} visibleMonths={months} maxView="day" weekdayStyle="twoletter">
<div className="grid gap-x-8 gap-y-2" style={{ gridTemplateColumns: `repeat(${months}, auto)` }}>
<div className="flex items-center gap-1">
<Calendar.Heading offset={0} className="ps-2.5 text-start" />
{months === 1 ? navCluster : null}
</div>
{months === 2 ? (
<div className="flex items-center gap-1">
<Calendar.Heading offset={1} className="ps-2.5 text-start" />
{navCluster}
</div>
) : null}
<Calendar.Grid />
{months === 2 ? <Calendar.Grid offset={1} /> : null}
</div>
</RangeCalendar.Root>
);
}
Coarse ranges
Under a coarse minView the endpoints snap outward to period boundaries: picking Q1 and then Q3 selects January 1 through September 30. The endpoint pills fill; the span between them washes.
"use client";
import { Calendar, type CalendarRangeValue, RangeCalendar } from "@stridge/noctis/calendar";
import { useState } from "react";
export default function CalendarRangeQuarters() {
const [value, setValue] = useState<CalendarRangeValue | null>(null);
return (
<div className="flex flex-col items-center gap-3">
<RangeCalendar.Root minView="quarter" value={value} onValueChange={setValue}>
<Calendar.Header>
<Calendar.Previous />
<Calendar.Heading />
<Calendar.Next />
</Calendar.Header>
<Calendar.Grid />
</RangeCalendar.Root>
<output className="text-small text-muted">
{value ? `${value.start.toString()} → ${value.end.toString()}` : "Q1 → Q3 selects the full three quarters"}
</output>
</div>
);
}
In a dialog
The full filter-dialog recipe: a Dialog shell owning the operator chips, the pill granularity strip, a draft selection, and the Cancel/Apply pair — the calendar itself is unchanged. The day tab is the two-month spread (per-grid titles, one jump-to-today + pager cluster). Each granularity locks its view with minView + maxView, the coarse views scroll instead of paging (so only the day view keeps a header), and the picker sits in a fixed-height region — a blockSize plus the --noctis-calendar-timeline-inline-size / -max-height minted overrides — so switching tabs stretches the period pills to the dialog and never shifts the layout. Apply reads the draft's details.range to describe coarse periods.
"use client";
import { Button } from "@stridge/noctis/button";
import { Calendar, type CalendarValueDetails, type CalendarView } from "@stridge/noctis/calendar";
import { Dialog } from "@stridge/noctis/dialog";
import type { DateValue } from "@stridge/noctis/i18n";
import { Tabs } from "@stridge/noctis/tabs";
import { Toggle, ToggleGroup } from "@stridge/noctis/toggle";
import { type CSSProperties, useState } from "react";
const GRANULARITIES: ReadonlyArray<[CalendarView, string]> = [
["day", "Day"],
["month", "Month"],
["quarter", "Quarter"],
["halfyear", "Half-year"],
["year", "Year"],
];
const OPERATORS = ["on", "before", "after"] as const;
type Operator = (typeof OPERATORS)[number];
/*
* One fixed-height picker region for every granularity — switching tabs never shifts the dialog.
* The coarse timeline is pinned to the day grid's width by default; the overrides stretch it to
* the dialog and let it fill the region exactly.
*/
const PICKER_REGION = {
blockSize: "20rem",
"--noctis-calendar-timeline-inline-size": "100%",
"--noctis-calendar-timeline-max-height": "100%",
} as CSSProperties;
/* Sized to the two-month spread: the grids reach the dialog's padding on both sides. */
const DIALOG_WIDTH = { "--noctis-dialog-popup-max-inline-size": "37rem" } as CSSProperties;
interface Draft {
value: DateValue;
range: CalendarValueDetails["range"];
}
export default function CalendarDialogPicker() {
const [granularity, setGranularity] = useState<CalendarView>("day");
const [operator, setOperator] = useState<Operator>("on");
const [draft, setDraft] = useState<Draft | null>(null);
const [applied, setApplied] = useState<string | null>(null);
return (
<Dialog.Root>
<Dialog.Trigger render={<Button variant="outline">{applied ?? "Completed date"}</Button>} />
<Dialog.Content size="lg" style={DIALOG_WIDTH}>
<Dialog.Header>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<Dialog.Title>Completed date</Dialog.Title>
{granularity === "day" ? (
<ToggleGroup
aria-label="Operator"
value={[operator]}
onValueChange={(next) => {
const [op] = next as Operator[];
if (op) setOperator(op);
}}
>
{OPERATORS.map((op) => (
<Toggle key={op} value={op} variant="outline" size="xs" className="rounded-full">
{op}
</Toggle>
))}
</ToggleGroup>
) : (
<span className="text-small text-muted">in</span>
)}
</div>
{/* Remount per granularity: each tab is its own picker locked to that single view. */}
<Tabs.Root
variant="chip"
size="sm"
value={granularity}
onValueChange={(value) => {
setGranularity(value as CalendarView);
setDraft(null);
}}
>
<Tabs.List>
{GRANULARITIES.map(([value, label]) => (
<Tabs.Tab key={value} value={value}>
{label}
</Tabs.Tab>
))}
<Tabs.Indicator />
</Tabs.List>
</Tabs.Root>
</div>
</Dialog.Header>
<Dialog.Body>
<Calendar.Root
key={granularity}
minView={granularity}
maxView={granularity}
visibleMonths={granularity === "day" ? 2 : undefined}
weekdayStyle="twoletter"
value={draft?.value ?? null}
onValueChange={(value, details) => setDraft(value ? { value, range: details.range } : null)}
// `flex` lifts the root out of the body's line box — an inline-flex box's baseline
// shifts per view, which would nudge the dialog height when switching tabs.
className="flex w-full"
style={PICKER_REGION}
>
{/* The day tab is the two-month spread: a title above each grid, one nav
cluster (jump-to-today + pagers) at the row's end. The coarse views
scroll instead of paging, so they render the timeline alone. */}
{granularity === "day" ? (
<div className="grid flex-1 grid-cols-2 content-start gap-x-8 gap-y-2">
<Calendar.Heading offset={0} className="ps-2.5 text-start" />
<div className="flex items-center gap-1">
<Calendar.Heading offset={1} className="ps-2.5 text-start" />
<Calendar.Today />
<Calendar.Previous />
<Calendar.Next />
</div>
<Calendar.Grid />
<Calendar.Grid offset={1} />
</div>
) : (
<Calendar.Grid />
)}
</Calendar.Root>
</Dialog.Body>
<Dialog.Footer>
<Dialog.Close render={<Button variant="secondary">Cancel</Button>} />
<Dialog.Close
render={
<Button
variant="primary"
disabled={draft === null}
onClick={() => {
if (!draft) return;
setApplied(
granularity === "day"
? `Completed ${operator} ${draft.value.toString()}`
: `Completed in ${draft.range.start.toString()} → ${draft.range.end.toString()}`,
);
}}
>
Apply
</Button>
}
/>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
);
}
In a popover
The classic date-picker shell: the calendar in a Popover, closing on pick. (P2's DatePicker packages this recipe with a segmented input field.)
"use client";
import { Button } from "@stridge/noctis/button";
import { Calendar } from "@stridge/noctis/calendar";
import { type DateValue, getLocalTimeZone, today } from "@stridge/noctis/i18n";
import { Popover } from "@stridge/noctis/popover";
import { useState } from "react";
/** The picker-in-a-popover recipe: pick a date, the popover closes, the trigger reflects it. */
export default function CalendarPopoverPicker() {
const [value, setValue] = useState<DateValue | null>(today(getLocalTimeZone()));
const [open, setOpen] = useState(false);
return (
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger render={<Button variant="outline">{value ? value.toString() : "Pick a date"}</Button>} />
<Popover.Popup>
<Calendar.Root
value={value}
onValueChange={(next) => {
setValue(next);
setOpen(false);
}}
>
<Calendar.Header>
<Calendar.Previous />
<Calendar.Heading />
<Calendar.Next />
</Calendar.Header>
<Calendar.Grid />
</Calendar.Root>
</Popover.Popup>
</Popover.Root>
);
}
Week numbers
weekNumbers on Calendar.Grid adds a leading column of locale week ordinals (the CLDR minimal-days rule — ISO 8601 in ISO locales; localized digits) — presentation-only and hidden from screen readers, like the weekday header. The fixed grid widens by exactly one cell square.
| S | M | T | W | T | F | S | |
|---|---|---|---|---|---|---|---|
"use client";
import { Calendar } from "@stridge/noctis/calendar";
export default function CalendarWeekNumbers() {
return (
<Calendar.Root>
<Calendar.Header>
<Calendar.Previous />
<Calendar.Heading />
<Calendar.Next />
</Calendar.Header>
<Calendar.Grid weekNumbers />
</Calendar.Root>
);
}
Sizes
Three cell scales: sm for dense embedding (a filter panel, a compact popover), md (the default), and lg for standalone surfaces. The pagers ride one control size below the cells.
| S | M | T | W | T | F | S |
|---|---|---|---|---|---|---|
| S | M | T | W | T | F | S |
|---|---|---|---|---|---|---|
| S | M | T | W | T | F | S |
|---|---|---|---|---|---|---|
"use client";
import { Calendar } from "@stridge/noctis/calendar";
export default function CalendarSizes() {
return (
<div className="flex flex-wrap items-start justify-center gap-8">
{(["sm", "md", "lg"] as const).map((size) => (
<Calendar.Root key={size} size={size}>
<Calendar.Header>
<Calendar.Previous />
<Calendar.Heading />
<Calendar.Next />
</Calendar.Header>
<Calendar.Grid />
</Calendar.Root>
))}
</div>
);
}
Custom cells
Calendar.GridBody accepts a function child receiving each day's computed CellData — date, localized label, and every state flag — and typically returns a Calendar.Cell with custom content. Availability dots, prices, activity marks: the cell stays a real day button with its full spoken label.
| S | M | T | W | T | F | S |
|---|---|---|---|---|---|---|
"use client";
import { Calendar } from "@stridge/noctis/calendar";
export default function CalendarCustomCells() {
return (
<Calendar.Root>
<Calendar.Header>
<Calendar.Previous />
<Calendar.Heading />
<Calendar.Next />
</Calendar.Header>
<Calendar.Grid>
<Calendar.GridHead />
<Calendar.GridBody>
{(cell) => (
<Calendar.Cell cell={cell}>
<span className="relative">
{cell.label}
{cell.date.day % 7 === 0 && !cell.isOutsideMonth ? (
<span
aria-hidden
className="absolute inset-x-0 -bottom-1 mx-auto size-1 rounded-full bg-accent"
/>
) : null}
</span>
</Calendar.Cell>
)}
</Calendar.GridBody>
</Calendar.Grid>
</Calendar.Root>
);
}
Headless
Calendar.Root builds its engine from props. When an outer shell needs to own the state — a filter facet, a date-picker popover coordinating a field — build it yourself with useCalendarState(options) — or useRangeCalendarState(options) for a span — and hand it to Calendar.Provider / RangeCalendar.Provider; every part reads the engine through context either way, and the full state surface (focusedDate, visibleRange, selectDate, focusNextPage, …) is yours to drive.
Keyboard
| Key | Action |
|---|---|
| Tab | Moves focus into the grid, onto the focused day (roving tabindex — one tab stop). |
| ← / → | Previous / next day. Mirrored under RTL. |
| ↑ / ↓ | Same weekday in the previous / next week. |
| Home / End | First / last day of the month. |
| PageUp / PageDown | Previous / next month (the window pages with the cursor). |
| Shift + PageUp / PageDown | Previous / next year. |
| Enter / Space | Select the focused day. |
| Escape | Cancels a range selection's pending anchor. |
Crossing a month boundary with the arrows pages the calendar and keeps DOM focus on the day that took the cursor. On the coarse views the same map scales to periods: arrows step periods and rows, Home/End jump to the year's first/last period, PageUp/PageDown step a year (a decade with Shift), and the timeline scrolls to keep the cursor visible.
Anatomy
Compose the calendar from its parts. Calendar.Root owns the engine; every other part reads it through context.
Calendar.Root— builds the engine from the value/window/system props and names the region ("label, February 2026").Calendar.Provider— the external-engine root: takes the stateuseCalendarStatebuilt.Calendar.Header— the layout row for the pagers and heading, in composition order.Calendar.Heading— the formatted title (month in the day view, year on the timeline) and the zoom-out button while a coarser view exists (override the text viachildren).Calendar.Previous/Calendar.Next— ghost icon buttons paging the visible window; disabled at themin/maxedges.Calendar.Today— the jump-to-today button: moves the cursor (and the window) to today without selecting.RangeCalendar.Root/RangeCalendar.Provider— the range engines; every part above composes inside them unchanged.Calendar.Grid— the month table; owns the keyboard map. Zero-config it renders the header and body.Calendar.GridHead/Calendar.HeadCell— the weekday label row (hidden from screen readers — each day's label carries its weekday).Calendar.GridBody/Calendar.Row— the week rows;GridBodytakes the cell-render function.Calendar.Cell— one day: thegridcellband layer wrapping the round day button with the roving tabindex and full spoken label.
On the coarse views Calendar.Grid renders the scrollable year timeline instead of the table — a listbox of year sections (noctis-calendar-timeline, noctis-calendar-year-section, sticky noctis-calendar-year-label) whose period cells reuse the noctis-calendar-cell-trigger slot with their data-view stamped. The rendered year span extends lazily as it scrolls.
Every rendered part carries a data-slot (noctis-calendar on the root, noctis-calendar-cell-trigger on a day button, and so on), and cells stamp their live state — data-selected, data-today, data-unavailable, data-outside-month, data-disabled, data-invalid, plus the range vocabulary data-range-start / data-range-end / data-in-range / data-preview — for host-side styling.
On surfaces
The same calendar re-tuned across the elevation scopes — the root canvas, an elevated panel, a menu, and a sunken well.
| S | M | T | W | T | F | S |
|---|---|---|---|---|---|---|
| S | M | T | W | T | F | S |
|---|---|---|---|---|---|---|
| S | M | T | W | T | F | S |
|---|---|---|---|---|---|---|
| S | M | T | W | T | F | S |
|---|---|---|---|---|---|---|
Design tokens
Generated from the component's declaration — the same graph that mints the CSS, so a variable name or its resolution default can't drift. The minted tokens are the public override seam: set one on any ancestor and every calendar in that region retunes — e.g. .sidebar { --noctis-calendar-cell-trigger-size: var(--noctis-size-control-sm); } compacts the grid. Colours aren't minted — cells read the foreground/muted/subtle roles, hover reads the neutral ghost wash, and the selected day reads the accent roles — so a retheme propagates for free. See Customization for the full override ladder and Tokens for the whole graph.
API reference
Generated from the component's types — every prop, type, default, and description comes straight from the source. Each part gets its own table. Expand a row for the full type and description.