sveltelean Primitives
Versionv0.2.0 GitHub

Example

A month

September 2026

MonTueWedThuFriSatSun
<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>
The grid is rendered once from calendarMonth(), on the server; the behavior only rewrites attributes and text. Try the arrow keys, PageUp and PageDown, the year and month buttons, and the RTL switch.

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/primitives
+layout.svelte or any client module
import '@svelte-lean/primitives/calendar/register';
stylesheets
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.

chosen.svelte
<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

PartElementdata-slean-partRequiredNotes
root<div data-slean="calendar">–yesOptions: data-slean-value, -month, -mode (single, range, multiple), -weekstart, -min, -max, -offdays, -input.
header<div>headerstyles onlyGroups 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="…">titleyesThe 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-yearnoShows the same month a year earlier; aria-disabled="true" past min.
previous<button type="button" aria-label="Previous month">previousnoaria-disabled="true" when no earlier month holds a selectable day.
next<button type="button" aria-label="Next month">nextnoaria-disabled="true" when no later month holds a selectable day.
next-year<button type="button" aria-label="Next year">next-yearnoShows the same month a year later; aria-disabled="true" past max.
grid<table role="grid" aria-labelledby="…">gridyesOne 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>dayyesExactly 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" 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) and aria-disabled="true" past min or max; their icons are CSS masks with no text.

Keyboard

KeyWhenResult
Enter/Spacefocus on a dayChooses it (native activation)
ArrowLeft/ArrowRightfocus on a dayPrevious or next day, into the neighbouring month; swapped under dir="rtl"
ArrowUp/ArrowDownfocus on a daySame weekday a week earlier or later
Home/Endfocus on a dayFirst or last day of the week
PageUp/PageDownfocus on a daySame day of the previous or next month, clamped to its last day
Shift+PageUp/Shift+PageDownfocus on a daySame day of the previous or next year

Platform features

FeatureBaselineOutside the target
Intl.DateTimeFormatWidely available (Baseline since 2017)Not applicable
:dir()Baseline 2023The 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:

TokenDefault (light)Applies to
--slean-space-61.5remcolumn-gap
--slean-space-20.5remrow-gap, padding, column-gap, padding-inline, padding-block-end
--slean-space-30.75rempadding, margin-inline
--slean-fgvar(--slean-neutral-12)color
--slean-color-schemelightcolor-scheme
--slean-text-sm0.875remfont-size
--slean-bordervar(--slean-neutral-6)border-block-end
--slean-font-weight-semibold600font-weight
--slean-leading1.5line-height
--slean-control-height-sm2reminline-size, block-size, min-inline-size
--slean-radius-sm0.375remborder-radius
--slean-fg-mutedvar(--slean-neutral-11)color
--slean-duration-fast100mstransition
--slean-easecubic-bezier(0.2, 0, 0, 1)transition
--slean-icon-chevron-lefturl("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-righturl("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-lefturl("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-righturl("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-accentoklch(54% 0.19 258)border-color, background, border-block
--slean-accent-fgoklch(100% 0 0)color
--slean-accent-softoklch(95% 0.03 258)background
--slean-mutedvar(--slean-neutral-3)background
--slean-control-height-md2.25remmin-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

MonTueWedThuFriSatSun

September 2026

MonTueWedThuFriSatSun

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

MonTueWedThuFriSatSun
MonTueWedThuFriSatSun
two months
<!-- 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

PztSalÇarPerCumCmtPaz
options
<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

Source