# Calendar

> A month grid with single, range and multiple selection, year and month buttons, two months for a range with a preview, keyboard across months and locale titles; state in attributes, no memory.

Page: https://svelte-lean.vercel.app/primitives/calendar · Markdown: https://svelte-lean.vercel.app/primitives/calendar.md · Index: https://svelte-lean.vercel.app/llms.txt

An inline month grid for choosing one date, a range or several dates, shown on the page rather than in a form field.

```svelte
<script lang="ts">
	import '@svelte-lean/styles/calendar.css';
	import { calendarHTML } from '@svelte-lean/primitives/calendar';

	// The header and the 42 cells, rendered once: after hydration the behavior owns them.
	const html = calendarHTML({ id: 'due', value: '2026-09-28' });
</script>

<div id="due" data-slean="calendar" data-slean-month="2026-09" data-slean-value="2026-09-28">
	{@html html}
</div>
```

Try the arrow keys, PageUp and PageDown, and the month and year buttons.

## Examples

### Range

```svelte
<script lang="ts">
	import { calendarHTML } from '@svelte-lean/primitives/calendar';

	const html = calendarHTML({ id: 'trip', mode: 'range', value: '2026-09-10/2026-09-18' });
</script>

<div id="trip" data-slean="calendar" data-slean-mode="range" data-slean-month="2026-09"
	data-slean-value="2026-09-10/2026-09-18">
	{@html html}
</div>
```

The first choice opens the range, the second closes it; the value is an ISO 8601 interval.

### Two months

```svelte
<script lang="ts">
	import { calendarHTML } from '@svelte-lean/primitives/calendar';

	// Two grids and two titles: the second grid shows the month after data-slean-month.
	const html = calendarHTML({ id: 'stay', mode: 'range', months: 2, month: '2026-09',
		value: '2026-09-24/2026-10-06' });
</script>

<div id="stay" data-slean="calendar" data-slean-mode="range" data-slean-month="2026-09"
	data-slean-value="2026-09-24/2026-10-06">
	{@html html}
</div>
```

One day across both grids holds the tab stop; the month buttons shift both.

### Multiple dates

```svelte
<script lang="ts">
	import { calendarHTML } from '@svelte-lean/primitives/calendar';

	// The value is a space-separated list of dates.
	const html = calendarHTML({ id: 'shifts', mode: 'multiple',
		value: '2026-09-02 2026-09-09 2026-09-16' });
</script>

<div id="shifts" data-slean="calendar" data-slean-mode="multiple" data-slean-month="2026-09"
	data-slean-value="2026-09-02 2026-09-09 2026-09-16">
	{@html html}
</div>
```

Each choice toggles a day; the grid carries aria-multiselectable.

### In a form

```svelte
<script lang="ts">
	import { calendarHTML } from '@svelte-lean/primitives/calendar';

	const html = calendarHTML({ id: 'delivery', value: '2026-09-28' });
</script>

<!-- A date input inside the root, named by data-slean-input, carries the form value: the calendar
     writes it, and follows a form reset and setValue() on it. -->
<form>
	<div id="delivery" data-slean="calendar" data-slean-month="2026-09"
		data-slean-value="2026-09-28" data-slean-input="delivery-date">
		<input id="delivery-date" type="date" name="delivery" value="2026-09-28" hidden />
		{@html html}
	</div>
	<button type="reset" data-slean="button" data-variant="outlined" data-color="neutral">Reset</button>
</form>
```

The date input inside the root carries the value; Reset brings the calendar back to it.

### Reading the value

```svelte
<script lang="ts">
	import { on } from 'svelte/events';
	import { calendarHTML, type CalendarChangeDetail } from '@svelte-lean/primitives/calendar';

	const html = calendarHTML({ id: 'chosen', value: '2026-09-28' });
	let chosen = $state('2026-09-28');
	let note = $state('');

	// slean:change is cancelable and dispatched before the calendar writes anything.
	const guard = (node: HTMLElement) =>
		on(node, 'slean:change', (event) => {
			const { value } = (event as CustomEvent<CalendarChangeDetail>).detail;
			if (value.endsWith('-13')) {
				event.preventDefault();
				note = 'The 13th is refused.';
			} else {
				chosen = value;
				note = '';
			}
		});
</script>

<div id="chosen" data-slean="calendar" data-slean-month="2026-09" data-slean-value="2026-09-28"
	{@attach guard}>
	{@html html}
</div>
<p role="status">Chosen: {chosen}. {note}</p>
```

The event is cancelable: choose a 13th and the calendar keeps the previous day.

### Jump to month and year

```svelte
<script lang="ts">
	import { calendarHTML } from '@svelte-lean/primitives/calendar';

	// selects: true writes the month and year selects into the header (the year buttons give way to
	// them). The years run from min's to max's (1900 to 2099 without bounds); yearRange: [2026,
	// 1926] would list the newest first.
	const html = calendarHTML({ id: 'birth', value: '1990-09-15', min: '1926-01-01',
		max: '2026-12-31', selects: true });
</script>

<div id="birth" data-slean="calendar" data-slean-month="1990-09" data-slean-value="1990-09-15"
	data-slean-min="1926-01-01" data-slean-max="2026-12-31">
	{@html html}
</div>
```

selects: true puts two native selects in the header: any month between data-slean-min and -max is two choices away.

### Bounds, off days and locale

```svelte
<script lang="ts">
	import { calendarHTML } from '@svelte-lean/primitives/calendar';

	// The same options as the attributes: the first render and the behavior agree.
	const html = calendarHTML({ id: 'booking', locale: 'tr', month: '2026-09',
		min: '2026-09-07', max: '2026-10-20', disabledWeekdays: [0, 6] });
</script>

<!-- The locale is the nearest lang: titles, weekday and button names in Turkish. -->
<div lang="tr">
	<div id="booking" data-slean="calendar" data-slean-month="2026-09" data-slean-min="2026-09-07"
		data-slean-max="2026-10-20" data-slean-offdays="0 6">
		{@html html}
	</div>
</div>
```

The locale is the nearest lang: this one starts on Monday and names its buttons in Turkish.

## Usage

Install `@svelte-lean/primitives` and `@svelte-lean/styles`. With `@svelte-lean/vite` the behavior registers itself for every `data-slean="calendar"`; without it, import the register module once.

```bash
npm install @svelte-lean/primitives
```

`+layout.svelte or any client module`

```ts
import '@svelte-lean/primitives/calendar/register';
```

`stylesheets`

```ts
import '@svelte-lean/styles/calendar.css';
```

Write the root with its options and its id, and render the inside with `calendarHTML({ id, …the same options })` once (a `const`): after hydration the behavior rewrites the cells, so the application must not write them again. Listen to `slean:change` (`onslean:change`) for the chosen value and `slean:navigate` for the shown month.

For a form value, put a date input inside the root and name it with `data-slean-input`: the calendar writes it and follows a form reset and `setValue()`. For a field with a popup, use the Date picker, which generates its calendar.

## 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. The first title is hidden visually while the month and year selects are present. |
| 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. |
| month | `<select aria-label="Month">` | `month` | no | Twelve options valued 1 to 12 (calendarMonthNames()), written by calendarHTML({ selects: true }); jumps to that month of the shown year. Months outside min and max are disabled for the shown year. |
| year | `<select aria-label="Year">` | `year` | no | Options valued by year (calendarYears(); calendarHTML({ selects: true }) lists min’s to max’s year, 1900 to 2099 without them, or yearRange); jumps to the shown month of that year, clamped to min and max. Shows no value while the shown year has no option. |
| 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. |

## 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 |
| `ArrowUp`/`ArrowDown` | focus on the month or year select | The platform’s select keyboard; each change shows that month |

## 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 compact month and year selects, 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. `data-color` on the root or an ancestor colors the selected days, today's ring and the range band (primary by default). The tokens it reads:

| Token | Default (light) | Applies to |
| --- | --- | --- |
| `--slean-space-6` | `calc(var(--slean-spacing) * 6)` | column-gap |
| `--slean-space-2` | `calc(var(--slean-spacing) * 2)` | row-gap, padding, column-gap, padding-inline, padding-block-end, background-position |
| `--slean-space-3` | `calc(var(--slean-spacing) * 3)` | 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` | min-block-size, inline-size, block-size, min-inline-size |
| `--slean-control-border` | `var(--slean-border-strong)` | border |
| `--slean-radius-sm` | `calc(var(--slean-radius) * 0.6)` | border-radius |
| `--slean-control-icon` | `var(--slean-fg-muted)` | background |
| `--slean-control-bg` | `var(--slean-surface)` | background |
| `--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-color-main` | `var(--slean-primary-main)` | border-color, background, border-block |
| `--slean-color-contrast-text` | `var(--slean-primary-contrast-text)` | color |
| `--slean-color-soft` | `var(--slean-primary-soft)` | background |
| `--slean-muted` | `var(--slean-neutral-3)` | background |
| `--slean-control-border-hover` | `initial` | border-color |
| `--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`).

## Under the hood

Runtime and size

- **Tier**: 1 · Delegated micro-behavior
- **Behavior JS**: 4594 B brotli · 5012 B gzip · `packages/primitives/artifacts/size.json` · [method](https://svelte-lean.vercel.app/performance/methodology.md)
- **Platform features**: `role="grid"`, `aria-selected`, `aria-current="date"`, `Intl.DateTimeFormat`, `Intl.RelativeTimeFormat`, `:dir(rtl)`
- **Shared listeners**: click, keydown, change, reset, pointerover, pointerout
- **Per-instance listeners**: none
- **Lazy state**: none
- **Native base**: `role=grid of day buttons in a <table>`

The calendar registers `click`, `keydown`, `change`, `reset`, `pointerover` and `pointerout` handlers with the shared router; `change` comes from the month and year selects and the inputs inside the root, `reset` from their form, and the pointer handlers draw the preview of an open range (`data-slean-preview`). 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`).

Why this implementation

The platform has a date input with its own picker, and that is the native path for entering a date (see Input). What it does not have is an inline month: a grid to look at, choose from and move through by keyboard. A visible month must be in the server markup, so `calendarHTML()` returns the header and the 42 cells as a string, pure and deterministic, for `{@html}` in any template on the server and in the browser; `calendarMonth()` returns the model for a template that renders the cells itself.

The behavior 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 month and year selects: their picker, keyboard and accessible names
- the click, keydown, change, reset, pointerover and pointerout events routed to the behavior

### Svelte Lean owns

- calendarHTML(): the header and the 42 cells of one or two months as a string for {@html}, the same on the server and in the browser; calendarMonth(): the model for a template that renders the cells itself
- calendarLabels(): the buttons’ names in the locale from Intl.RelativeTimeFormat, the selects’ from Intl.DisplayNames
- 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
- calendarMonthNames() and calendarYears() for the month and year selects; showing the chosen month clamped to min and max, and writing the selects back on every month change
- 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; following setValue() and a form reset of the inputs inside the root
- the cancelable slean:change and slean:navigate events, development validation, calendar.css

Accessibility

- `role="grid"` labelled by the title; `role="gridcell"` with `aria-selected` on each cell; `aria-multiselectable="true"` in multiple mode.
- Each day button is named by its full date in the locale (`aria-label`); today carries `aria-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`, in the locale by default: "Last month", "Next year") and `aria-disabled="true"` past `min` or `max`; their icons are CSS masks with no text.
- The month and year selects are native `<select>` elements with names (`aria-label`); the grid keeps the title as its name while the styles hide the title visually.

Platform features

| Feature | Baseline | Outside the target |
| --- | --- | --- |
| `Intl.DateTimeFormat` | Widely available (Baseline since 2017) | Not applicable |
| `Intl.RelativeTimeFormat` | Baseline 2020 | Not applicable within the support policy |
| `: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: its native date input keeps working.

Server rendering and hydration

`calendarHTML()` and `calendarMonth()` are pure; render the inside on the server with the same options the attributes carry. Pass `today` explicitly (or `null` on a prerendered page, and the behavior marks today in the browser) if a page may be rendered and hydrated on different days. `calendarHTML()` with `selects: true` writes the month and year selects with the shown month selected; a template renders them with `calendarMonthNames(locale)` and `calendarYears(min, max)`.

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.

Compatibility

Nothing beyond buttons, native selects, tables, ARIA grid roles and `Intl.DateTimeFormat`, all widely available (`Intl.DisplayNames` names the selects where it exists, Baseline 2022). Week numbers and month and year views are not part of the primitive: the month and year selects jump to a month instead of a year view. A time is the Date picker's, over a `datetime-local` input.

Tests and source

- `packages/primitives/tests/calendar.test.ts` Vitest: the model, calendarHTML(), keyboard across months, RTL, modes, min/max, inputs, setValue() and reset, 1000 calendars
- `apps/playground/tests/primitives/calendar.spec.ts` Playwright: the grid, the range picker, the selects and a calendarHTML() calendar in a form in Chromium
- `packages/styles/tests` static checks of the stylesheet (layers, tokens, specificity, dark parity)

- `packages/primitives/src/calendar/contract.md` the contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behavior
- `packages/primitives/src/calendar/contract.ts` typed constants: name, tier, base, parts, options, events
- `packages/primitives/src/calendar/model.ts` calendarMonth() and the date arithmetic, pure and SSR-safe
- `packages/primitives/src/calendar/markup.ts` calendarHTML() and calendarLabels(), pure and SSR-safe
- `packages/primitives/src/calendar/behavior.ts` the behavior definition
- `packages/primitives/src/calendar/register.ts` the registration module
- `packages/styles/css/calendar.css` the optional stylesheet
