Calendar
A month grid that selects a day, a set of days, or a range — locale-aware, keyboard-complete, and popup-free.
Live demo
Calendar is the month grid itself: a controlled role="grid" of day cells with no opinion about being inside a popup (that is DatePicker's job, and it consumes this). Three modes: single picks one day, multiple toggles a set, and range opens on the first pick and closes on the second, previewing the pending half as the pointer — or the keyboard cursor — sweeps across the grid.
Every date is a wall-clock date, an ISO YYYY-MM-DD civil day rather than an instant, so nothing here can shift a selection across a DST boundary; today is read in the host's timezone, or whichever IANA zone timezone names. The locale drives the month and weekday names, the numbering system, and the first day of the week, all through Intl (Intl.Locale's weekInfo where the engine has it, ISO-8601 otherwise) — no bundled locale table and no date library — and firstDayOfWeek overrides it outright. min / max, a disabledDates list, and an isDateDisabled predicate compose into which days are refused, and decorations hangs event dots, a busy bar, and extra announced text on any day. The keyboard is the full WAI-ARIA date grid: arrows walk days and weeks straight across month boundaries, Home / End snap to the week's edges, PageUp / PageDown step months (Shift for years), and Escape cancels a half-drawn range.
When to use
How this component composes with the rest of the set.
Props
22 props, straight from the manifest.
| Prop | Type | Default | Bindings | Description |
|---|---|---|---|---|
Appearance
Variants
single
One day at a time; a second pick replaces the first.
multiple
A set of days, each click toggling one in or out.
range
Two ends and the band between them, with the pending half previewed under the pointer.
Sizes
sm
Compact — for a dense sidebar or a popup.
md
Default.
lg
Large — a page-level scheduler.
States
selected
A picked day: a filled pill in the tone. The whole of a range is aria-selected="true" — it is all picked — but only its two ends fill; the days between carry the band, so the selection reads as one span rather than a row of identical pills.
in-range
A day inside a closed range: the soft band, edge-to-edge across the row.
preview
The pending half of a range, following the pointer (or the keyboard cursor, so the preview is not mouse-only).
today
Today, ringed rather than filled, and marked aria-current="date".
outside
An adjacent-month day: dimmed, still selectable, and picking one steps the grid onto its month.
disabled
A refused day — outside min/max, listed, or caught by the predicate. Still focusable, never selectable.
focus-visible
The keyboard cursor: a token-colored ring, plus a transparent outline promoted to a real one in forced-colors mode.
Anatomy
The named parts that make up the component, with their selectors.
calendar
The root; carries the tone, the size, and the mode. Becomes a labelled group when given a label.
header
The month bar: the previous / next steps around the month heading.
title
The month and year, localized. An aria-live="polite" region, so stepping the month announces the new one.
nav
A month step. Marked aria-disabled and made inert when the next month is entirely past min / max.
weekday
A column header, rotated to the locale's first day. Carries the full weekday name in abbr.
weeknum
The optional week-of-year row header, generalized from ISO-8601 over the locale's week rules.
cell
A day: the gridcell that carries the roving tab stop, the selection state, and the range band.
day
The pill inside a cell — the box that fills when the day is selected and rings when it is today.
dot
A decoration dot under the day number, one per event, colored by tone. A real element, so a mod can reshape it.
busy
The bar marking a fully-booked day.
cue
The non-color selection cue. Drawn only when the theme's --selection-cue resolves to marker (a high-contrast or redundant-cues algorithm), so a selected day never rests on color alone.
Tokens & coverage
What the component consumes, checked live against what the algorithm produces.
Live coverage check against the xtyle-default register
(derive(xtyleDefault, { anchors }) →
coverComponent(manifest, register)). Every token this component
consumes must be a key the algorithm produces.
--accent
--accent-2
--accent-2-bg
--accent-2-fg
--accent-3
--accent-3-bg
--accent-3-fg
--accent-4
--accent-4-bg
--accent-4-fg
--accent-bg
--accent-fg
--black
--black-bg
--black-fg
--blue
--blue-bg
--blue-fg
--border-normal
--border-thick
--border-thin
--brown
--brown-bg
--brown-fg
--cyan
--cyan-bg
--cyan-fg
--danger
--danger-bg
--danger-fg
--duration-fast
--ease-standard
--fg-0
--fg-1
--fg-2
--fg-3
--fg-disabled
--font-sans
--gray
--gray-bg
--gray-fg
--green
--green-bg
--green-fg
--info
--info-bg
--info-fg
--leading-normal
--neutral
--neutral-bg
--neutral-fg
--orange
--orange-bg
--orange-fg
--pink
--pink-bg
--pink-fg
--purple
--purple-bg
--purple-fg
--radius-full
--radius-sm
--red
--red-bg
--red-fg
--ring
--selection-cue
--space-0
--space-1
--state-hover
--success
--success-bg
--success-fg
--text-body
--text-sm
--text-xs
--warn
--warn-bg
--warn-fg
--weight-medium
--weight-semibold
--white
--white-bg
--white-fg
--yellow
--yellow-bg
--yellow-fg
Accessibility
Code
Modes, limits, and day decorations
A single-day grid and a bounded range with week numbers, a disabled-day predicate, and dots and busy bars hung on days.
<!-- a single day, with the locale deciding where the week starts -->
<xtyle-calendar id="cal" mode="single" value="2026-07-14" locale="en-GB"></xtyle-calendar>
<!-- a range, six fixed rows so the grid never jumps, weekends and events marked -->
<xtyle-calendar id="trip" mode="range" fixed-weeks week-numbers min="2026-07-01" max="2026-08-31"></xtyle-calendar>
<script>
const trip = document.getElementById("trip");
// a predicate says which days are refused — here, every Monday
trip.isDateDisabled = (iso) => new Date(iso + "T00:00:00Z").getUTCDay() === 1;
// decorations mark days: dots for events, a bar for a full day, text for the announcement
trip.decorations = {
"2026-07-16": { dots: ["accent", "success"], label: "2 events" },
"2026-07-17": { busy: true, label: "fully booked" },
};
trip.addEventListener("change", (e) => {
const { start, end, complete } = e.detail;
if (complete) console.log(`${start} → ${end}`);
});
</script>
<script lang="ts">
import { Calendar } from "@xtyle/svelte";
let value = $state("2026-07-14");
let month = $state("2026-07");
</script>
<Calendar
mode="single"
{value}
{month}
min="2026-07-01"
decorations={{ "2026-07-16": { dots: ["success"], label: "1 event" } }}
isDateDisabled={(iso) => iso.endsWith("-25")}
onchange={(e) => (value = e.detail.value)}
onmonthchange={(e) => (month = e.detail.month)}
/>
<p>Picked: {value || "nothing yet"}</p>
---
import Calendar from "@xtyle/astro/Calendar.astro";
---
<!-- server-rendered to real markup: the month grid is there before any JS loads -->
<Calendar
mode="range"
value="2026-07-14,2026-07-19"
month="2026-07"
locale="fr-FR"
weekNumbers
fixedWeeks
label="Dates du séjour"
/>