Primitives Date and time
Calendar
A month grid with single, range and multiple selection and the keyboard of the WAI-ARIA date grid. The shown month and the selection live in attributes, so the behavior keeps no memory: changing the month rewrites the same 42 cells. Tier 1.
- Tier
- 1 · Delegated micro-behavior
- Behavior JS
- 3925 B brotli · 4268 B gzip ·
packages/· methodprimitives/ artifacts/ size.json - Platform features
role="grid",aria-selected,aria-current="date",Intl.DateTimeFormat,:dir(rtl)- Shared listeners
- click, keydown, pointerover, pointerout
- Per-instance listeners
- none
- Lazy state
- none
- Native base
role=grid of day buttons in a <table>
On this page
Example
September 2026
| Mon | Tue | Wed | Thu | Fri | Sat | Sun |
|---|---|---|---|---|---|---|
<div data-slean="calendar" data-slean-month="2026-09" data-slean-value="2026-09-28">
<div data-slean-part="header">
<button type="button" data-slean-part="previous-year" aria-label="Previous year"></button>
<button type="button" data-slean-part="previous" aria-label="Previous month"></button>
<h3 id="due-title-0" data-slean-part="title">September 2026</h3>
<button type="button" data-slean-part="next" aria-label="Next month"></button>
<button type="button" data-slean-part="next-year" aria-label="Next year"></button>
</div>
<table role="grid" data-slean-part="grid" aria-labelledby="due-title-0">
<thead>
<tr>
<th scope="col" abbr="Monday">Mon</th>
<!-- … Tuesday to Sunday -->
</tr>
</thead>
<tbody>
<tr>
<td role="gridcell" aria-selected="false">
<button type="button" data-slean-part="day" data-slean-value="2026-08-31"
aria-label="Monday, August 31, 2026" data-slean-outside tabindex="-1">31</button>
</td>
<td role="gridcell" aria-selected="false">
<button type="button" data-slean-part="day" data-slean-value="2026-09-01"
aria-label="Tuesday, September 1, 2026" tabindex="-1">1</button>
</td>
<!-- … 42 cells in six rows; the chosen day has aria-selected="true" on its cell
and tabindex="0" on its button -->
</tr>
</tbody>
</table>
</div><!-- Month.svelte: the inside of a calendar root, rendered once. -->
<script lang="ts">
import { untrack } from 'svelte';
import { addMonths, calendarMonth, type CalendarOptions } from '@svelte-lean/primitives/calendar';
let { id, month, options = {}, months = 1 }: {
id: string; month: string; options?: CalendarOptions; months?: 1 | 2;
} = $props();
// After hydration the calendar behavior owns the cells and rewrites them when the month or
// the selection changes, so the application renders them once and never again.
const models = untrack(() =>
Array.from({ length: months }, (_, i) => calendarMonth(addMonths(month, i), options))
);
</script>
<div data-slean-part="header">
<button type="button" data-slean-part="previous-year" aria-label="Previous year"></button>
<button type="button" data-slean-part="previous" aria-label="Previous month"></button>
{#each models as model, i (i)}
<h3 id="{id}-title-{i}" data-slean-part="title">{model.title}</h3>
{/each}
<button type="button" data-slean-part="next" aria-label="Next month"></button>
<button type="button" data-slean-part="next-year" aria-label="Next year"></button>
</div>
{#each models as model, i (i)}
<table role="grid" data-slean-part="grid" aria-labelledby="{id}-title-{i}">
<thead>
<tr>
{#each model.weekdays as w (w.long)}<th scope="col" abbr={w.long}>{w.short}</th>{/each}
</tr>
</thead>
<tbody>
{#each [0, 1, 2, 3, 4, 5] as week (week)}
<tr>
{#each model.days.slice(week * 7, week * 7 + 7) as day, d (d)}
<td role="gridcell" aria-selected={day.selected}>
<button
type="button"
data-slean-part="day"
data-slean-value={day.date}
aria-label={day.label}
aria-disabled={day.disabled ? 'true' : undefined}
aria-current={day.today ? 'date' : undefined}
data-slean-outside={day.outside ? '' : undefined}
data-slean-edge={day.edge ?? undefined}
tabindex={day.tabbable && i === 0 ? 0 : -1}>{day.day}</button
>
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
{/each}
<!-- +page.svelte: the root with its static marker, where @svelte-lean/vite finds it. -->
<script lang="ts">
import '@svelte-lean/styles/calendar.css';
import Month from './Month.svelte';
</script>
<div data-slean="calendar" data-slean-month="2026-09" data-slean-value="2026-09-28">
<Month id="due" month="2026-09" options={{ value: '2026-09-28' }} />
</div>Why this implementation exists
The platform has a date input with its own picker, and that is the native path for entering a date (see Date field). What it does not have is an inline month: a grid to look at, choose from and move through by keyboard. Rendering 42 days is a template’s job, so calendarMonth() returns them, pure and deterministic, for the server and for any template.
The behavior has little to do and keeps nothing of its own. The shown month is data-slean-month, the selection is data-slean-value, the chosen cells carry aria-selected and one day holds tabindex="0". When the month changes, the same 42 buttons get new values, labels and states; no element is created or removed, so markup Svelte rendered stays the markup Svelte owns.
The browser owns
- the buttons: focus, Enter and Space activation, the accessible name from aria-label
- the table and its grid, gridcell and column header roles
- the month, weekday and date names in every locale (Intl.DateTimeFormat)
- the click, keydown, pointerover and pointerout events routed to the behavior
Svelte Lean owns
- calendarMonth(): the 42 days of a month for any template, on the server and in the browser
- choosing a day in single, range and multiple mode, min, max and off days
- two months side by side for a range, and data-slean-preview on the days a click would add to an open range
- arrow keys, Home and End, PageUp and PageDown across months and years; the month and year buttons
- rewriting the same 42 cells when the month changes; the roving tabindex
- writing the inputs named by data-slean-input and closing a surrounding popover
- the cancelable slean:change and slean:navigate events, development validation, calendar.css
Usage
Install @svelte-lean/primitives for the behavior and @svelte-lean/styles for the stylesheet. With @svelte-lean/vite the
registration is injected for every static data-slean="calendar"; without it, import
the register module once.
npm install @svelte-lean/primitivespnpm add @svelte-lean/primitivesyarn add @svelte-lean/primitivesbun add @svelte-lean/primitivesimport '@svelte-lean/primitives/calendar/register';import '@svelte-lean/styles/calendar.css';Render the root with its options, the header (year and month buttons, the title) and the 42 cells from calendarMonth(month, options). Pass the same options to the function and to the attributes. Listen to slean:change for the chosen value and slean:navigate for the shown month.
Render the cells once (untrack in Svelte): after hydration the behavior rewrites them when the month or the selection changes, so a later render of the application must not write them again.
For a value entered in a form, use the Date picker: its native date input holds the value and the calendar sits in its popup; the calendar alone has no form value.
<script lang="ts">
import type { CalendarChangeDetail } from '@svelte-lean/primitives/calendar';
let root: HTMLElement;
let chosen = $state('2026-09-28');
$effect(() => {
// slean:change is cancelable and dispatched before the calendar writes anything.
const onchange = (event: Event) => {
const { value } = (event as CustomEvent<CalendarChangeDetail>).detail;
if (value.endsWith('-13')) event.preventDefault();
else chosen = value;
};
root.addEventListener('slean:change', onchange);
return () => root.removeEventListener('slean:change', onchange);
});
</script>
<div bind:this={root} data-slean="calendar" data-slean-value="2026-09-28">…</div>
<p>Chosen: {chosen}</p>Anatomy
| Part | Element | data-slean-part | Required | Notes |
|---|---|---|---|---|
| root | <div data-slean="calendar"> | – | yes | Options: data-slean-value, -month, -mode (single, range, multiple), -weekstart, -min, -max, -offdays, -input. |
| header | <div> | header | styles only | Groups the year and month buttons and the titles on one row above the grids; the flat markup without it keeps its layout. |
| title | <h2 id="…"> | title | yes | The month and year, one per grid; each grid is labelled by its id. Rewritten on navigation. |
| previous-year | <button type="button" aria-label="Previous year"> | previous-year | no | Shows the same month a year earlier; aria-disabled="true" past min. |
| previous | <button type="button" aria-label="Previous month"> | previous | no | aria-disabled="true" when no earlier month holds a selectable day. |
| next | <button type="button" aria-label="Next month"> | next | no | aria-disabled="true" when no later month holds a selectable day. |
| next-year | <button type="button" aria-label="Next year"> | next-year | no | Shows the same month a year later; aria-disabled="true" past max. |
| grid | <table role="grid" aria-labelledby="…"> | grid | yes | One per month shown (two for a range picker). Column headers from calendarMonth().weekdays; aria-multiselectable="true" in multiple mode. |
| day | <td role="gridcell"><button data-slean-value="YYYY-MM-DD"></button></td> | day | yes | Exactly 42 per grid. aria-selected on the cell; aria-current="date", aria-disabled, data-slean-outside, data-slean-edge and data-slean-preview on the button. |
Runtime profile
The calendar registers click, keydown, pointerover and pointerout handlers with the shared router; the pointer handlers draw the preview of an open range. Its bytes, in the runtime block, are the production registration with the core kernel, the date model and the Intl formatting it calls. A thousand calendars keep one listener per type (tests/calendar.test.ts).
Accessibility contract
role="grid"labelled by the title;role="gridcell"witharia-selectedon each cell;aria-multiselectable="true"in multiple mode.- Each day button is named by its full date in the locale (
aria-label); today carriesaria-current="date". - Unavailable days carry
aria-disabled="true": focusable so the keyboard can cross them, never chosen. - One day is in the tab order; the arrow keys, Home, End, PageUp and PageDown move it, across months.
- The title is rewritten on every month change; announce it with
aria-live="polite"on the title when the grid is the only focus. - The year and month buttons have names (
aria-label) andaria-disabled="true"pastminormax; their icons are CSS masks with no text.
Keyboard
| Key | When | Result |
|---|---|---|
| Enter/Space | focus on a day | Chooses it (native activation) |
| ArrowLeft/ArrowRight | focus on a day | Previous or next day, into the neighbouring month; swapped under dir="rtl" |
| ArrowUp/ArrowDown | focus on a day | Same weekday a week earlier or later |
| Home/End | focus on a day | First or last day of the week |
| PageUp/PageDown | focus on a day | Same day of the previous or next month, clamped to its last day |
| Shift+PageUp/Shift+PageDown | focus on a day | Same day of the previous or next year |
Platform features
| Feature | Baseline | Outside the target |
|---|---|---|
Intl.DateTimeFormat | Widely available (Baseline since 2017) | Not applicable |
:dir() | Baseline 2023 | The month chevrons do not mirror under RTL |
Without JavaScript
The server-rendered month is visible and readable; days cannot be chosen and the month does not change. Where a value must be entered without JavaScript, use the Date picker composition: its native date input keeps working.
Server rendering
calendarMonth() is pure; render the grid on the server with the same options the attributes carry. Pass today explicitly if a page may be rendered and hydrated on different days.
Before hydration
Before the behavior loads, a click on a day does nothing and the arrow keys scroll. The registration attaches no per-root state, so the first event after hydration works on the server's markup directly.
Styling
calendar.css lays out the header and one or two grids, draws the year and month buttons with the chevron icon masks (mirrored under :dir(rtl)), the day buttons, today's ring, the selected cell, the soft band between range ends, the preview of an open range and the unavailable days. The tokens it reads:
| Token | Default (light) | Applies to |
|---|---|---|
--slean-space-6 | 1.5rem | column-gap |
--slean-space-2 | 0.5rem | row-gap, padding, column-gap, padding-inline, padding-block-end |
--slean-space-3 | 0.75rem | padding, margin-inline |
--slean-fg | var(--slean-neutral-12) | color |
--slean-color-scheme | light | color-scheme |
--slean-text-sm | 0.875rem | font-size |
--slean-border | var(--slean-neutral-6) | border-block-end |
--slean-font-weight-semibold | 600 | font-weight |
--slean-leading | 1.5 | line-height |
--slean-control-height-sm | 2rem | inline-size, block-size, min-inline-size |
--slean-radius-sm | 0.375rem | border-radius |
--slean-fg-muted | var(--slean-neutral-11) | color |
--slean-duration-fast | 100ms | transition |
--slean-ease | cubic-bezier(0.2, 0, 0, 1) | transition |
--slean-icon-chevron-left | url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M10 4L6 8l4 4' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E") | mask |
--slean-icon-chevron-right | url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M6 4l4 4-4 4' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E") | mask-image |
--slean-icon-chevrons-left | url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 4L4 8l4 4M12 4L8 8l4 4' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E") | mask-image |
--slean-icon-chevrons-right | url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4 4l4 4-4 4M8 4l4 4-4 4' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E") | mask-image |
--slean-accent | oklch(54% 0.19 258) | border-color, background, border-block |
--slean-accent-fg | oklch(100% 0 0) | color |
--slean-accent-soft | oklch(95% 0.03 258) | background |
--slean-muted | var(--slean-neutral-3) | background |
--slean-control-height-md | 2.25rem | min-inline-size, block-size |
State selectors the stylesheet targets, all from the platform or ARIA: :dir(rtl), :first-child, :hover, :last-child, [aria-current="date"], [aria-disabled="true"], [aria-selected="true"].
Variant attributes: data-slean-outside; data-slean-mode (range); data-slean-preview; data-slean-edge (start, end, both).
Compatibility notes
Nothing beyond buttons, tables, ARIA grid roles and Intl.DateTimeFormat, all widely available. Week numbers, month and year views, and time selection are not part of the primitive.
Examples
Range
data-slean-mode="range": the first choice opens the range, the second closes it
(earlier dates become the start). While only the start is chosen, the days between it and the
day under the pointer carry data-slean-preview. The value is an ISO 8601 interval, 2026-09-10/2026-09-18.
September 2026
| Mon | Tue | Wed | Thu | Fri | Sat | Sun |
|---|---|---|---|---|---|---|
September 2026
| Mon | Tue | Wed | Thu | Fri | Sat | Sun |
|---|---|---|---|---|---|---|
Left, a range; right, multiple dates (aria-multiselectable="true" on the grid).
Two months
Two grid parts and two title parts show the month of data-slean-month and the next one, as the range picker does. One day across both grids
holds the tab stop; the month and year buttons and moving past the last grid shift both.
September 2026
October 2026
| Mon | Tue | Wed | Thu | Fri | Sat | Sun |
|---|---|---|---|---|---|---|
| Mon | Tue | Wed | Thu | Fri | Sat | Sun |
|---|---|---|---|---|---|---|
<!-- Two grids and two titles: the second grid shows the month after data-slean-month. -->
<div data-slean="calendar" data-slean-mode="range" data-slean-month="2026-09"
data-slean-value="2026-09-10/2026-09-18">
<Month id="stay" month="2026-09" months={2}
options={{ mode: 'range', value: '2026-09-10/2026-09-18' }} />
</div>Bounds, off days and locale
data-slean-min, -max and -offdays make days unavailable;
the month buttons stop at the bounds. The locale is the nearest lang: this one is
Turkish, starts on Monday and knows nothing else about Turkey.
Eylül 2026
| Pzt | Sal | Çar | Per | Cum | Cmt | Paz |
|---|---|---|---|---|---|---|
<div
data-slean="calendar"
data-slean-mode="range"
data-slean-value="2026-09-10/2026-09-18"
data-slean-weekstart="0"
data-slean-min="2026-09-01"
data-slean-offdays="0 6"
>…</div>Testing
packages/primitives/tests/calendar.test.tsVitest: the model, keyboard across months, RTL, modes, min/max, inputs, 1000 calendarsapps/playground/tests/primitives/calendar.spec.tsPlaywright: the grid, the date picker and the range picker in Chromiumpackages/styles/testsstatic checks of the stylesheet (layers, tokens, specificity, dark parity)
Source
packages/primitives/src/calendar/contract.mdthe contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behaviorpackages/primitives/src/calendar/contract.tstyped constants: name, tier, base, parts, options, eventspackages/primitives/src/calendar/model.tscalendarMonth() and the date arithmetic, pure and SSR-safepackages/primitives/src/calendar/behavior.tsthe behavior definitionpackages/primitives/src/calendar/register.tsthe registration modulepackages/styles/css/calendar.cssthe optional stylesheet