sveltelean Primitives
Versionv0.2.0 GitHub

Example

Due date

The bound value: 2026-09-28

<label for="due-text">Due date</label>
<div id="due" data-slean="date-picker" data-slean-format="MM/DD/YYYY">
	<input id="due-date" type="date" name="due" value="2026-09-28" data-slean-part="native" />
	<input
		id="due-text"
		type="text"
		role="combobox"
		aria-haspopup="dialog"
		aria-expanded="false"
		aria-controls="due-popup"
		autocomplete="off"
		placeholder="Select date"
		value="09/28/2026"
		data-slean-part="input"
	/>
	<button type="button" tabindex="-1" aria-label="Clear" data-slean-part="clear"></button>
	<span data-slean-part="icon" aria-hidden="true"></span>
	<div id="due-popup" popover="manual" role="dialog" aria-label="Choose a date" data-slean-part="popup">
		<div data-slean="calendar" data-slean-month="2026-09" data-slean-value="2026-09-28"
			data-slean-input="due-date">
			<!-- the header and the grid, rendered once with calendarMonth() -->
		</div>
		<div data-slean-part="footer">
			<button type="button" data-slean-part="today">Today</button>
		</div>
	</div>
</div>
Click anywhere in the field to open the calendar, or type a date such as 10/2/2026: a date that parses moves the calendar at once. ArrowDown moves focus into the grid, Enter chooses, Escape closes. Today in the footer chooses the reader’s date; the clear button appears on hover.

Why this implementation exists

The native <input type="date"> has the ISO value, min, max, validation and form submission, and it is the right field without JavaScript. Its interface is not the one an application ships: a dd/mm/yyyy mask with no placeholder, a picker glyph that differs in every browser, a platform popup that cannot be styled and no range. ADR 0007 keeps the native input as the value and draws the field over it.

The owned field is the control shell every form control shares (control.css): a text input that shows the date in a display format and accepts typing, the clear button and the calendar icon inside the border, the whole field as the click target. The popup holds a Calendar in single or range mode and an optional footer with Today and preset ranges; the calendar writes the native inputs through data-slean-input, and the date picker writes the text back.

The browser owns

  • the ISO value, min and max, form submission and validation of the native date inputs
  • text editing in the formatted text input: typing, the caret, selection and IME composition
  • the popup in the top layer through showPopover() and hidePopover(), placed by CSS anchor positioning
  • month, weekday and date names in every locale (Intl.DateTimeFormat)
  • the native date inputs and their picker when scripting is off

Svelte Lean owns

  • the field: formatted text inputs, a clear button and a calendar icon inside one control shell (control.css)
  • formatDisplay(), parseDisplay() and dateFormat(): the display format from data-slean-format or the locale
  • reading typed text into the native inputs and the calendar; reverting text that does not parse
  • opening from the whole field, ArrowDown into the grid, Escape, Enter, an outside press and focus leaving the root
  • the range: two native and two text inputs, two months side by side, the preview of an open range, Tab from start to end
  • the Today button and preset ranges in the footer
  • the lazy controller, development validation, date-picker.css and the contract

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="date-picker"; without it, import the register module once.

npm install @svelte-lean/primitives
+layout.svelte or any client module
import '@svelte-lean/primitives/date-picker/register';
stylesheets
import '@svelte-lean/styles/control.css';
import '@svelte-lean/styles/date-field.css';
import '@svelte-lean/styles/calendar.css';
import '@svelte-lean/styles/date-picker.css';

Put the native date input (data-slean-part="native", two for a range), one text input per native input (role="combobox", aria-haspopup="dialog", a placeholder and a name), the clear button, the icon and a popover="manual" popup inside data-slean="date-picker". The calendar in the popup names the native inputs with data-slean-input, in order.

Render the text input’s value with formatDisplay(value, format) from @svelte-lean/primitives/date-picker, and the calendar once, with untrack in Svelte: after hydration the calendar behavior owns its cells. Bind the native input with bind:value; the behavior dispatches input and change on it.

The Date field (data-slean="date-field") remains the Tier 0 native input for a table cell or a form without JavaScript.

Anatomy

PartElementdata-slean-partRequiredNotes
root<div id="…" data-slean="date-picker">–yesThe field. Options: data-slean-format (YYYY, MM, DD, M, D and separators); styles: data-size, data-status.
native<input type="date" name="…">nativeyesOne, or two for a range: the ISO value, min and max; named by the calendar’s data-slean-input. Shown only without scripting.
input<input type="text" role="combobox" aria-haspopup="dialog" aria-controls="…">inputyesOne per native input: the formatted date, a placeholder and a name. Server-render its value with formatDisplay().
separator<span aria-hidden="true">separatornoBetween the two text inputs of a range; an arrow icon, mirrored under RTL.
clear<button type="button" tabindex="-1" aria-label="…">clearnoShown on hover or focus while there is a value; empties every native input.
icon<span aria-hidden="true">iconnoThe calendar icon, a mask from --slean-icon-calendar; a click on it opens or closes.
popup<div popover="manual" role="dialog" aria-label="…">popupyesHolds the calendar (render it once) and the footer.
footerany element in the popupfooternoHolds the today and preset buttons.
today<button type="button">todaynoChooses today and closes (single mode).
preset<button type="button" data-slean-value="YYYY-MM-DD/YYYY-MM-DD">presetnoSets the value, a date or a range, and closes.

Runtime profile

Tier 2: a lazy scoped controller. Seven shared listeners (click, keydown, input, change, pointerdown, focusout and toggle in the capture phase) serve every date picker on the page; the calendar inside registers its own click, keydown, pointerover and pointerout. A root gets a WeakMap entry on its first interaction and, while its popup is open, one document pointerdown listener that closes it on a press outside. The bytes in the runtime block include the calendar functions the date picker calls.

Accessibility contract

  • Each text input is a role="combobox" with aria-haspopup="dialog", aria-expanded (written) and aria-controls naming the popup; it has a label (or aria-label for the two inputs of a range) and keeps DOM focus while the pointer chooses.
  • The popup is a role="dialog" with a name. ArrowDown moves focus to the chosen day (else today); Escape closes and returns focus to the text input; choosing closes and returns focus there.
  • Typed text that parses and respects min and max updates the value at once; text that does not parse is replaced by the last valid value when it is committed, so the field never shows a date the form does not hold.
  • The clear button and the icon are out of the tab order (tabindex="-1", aria-hidden on the icon); the calendar’s own grid semantics apply inside the popup.

Keyboard

KeyWhenResult
characters/Backspace/Deletefocus in a text inputNative editing; a date that parses and respects min and max is written into the native input at once and the calendar follows
ArrowDownfocus in a text inputOpens the popup and moves focus to the chosen day, else today
Entertext input, popup openCommits the text (text that does not parse reverts) and closes
Escapepopup openCloses; from the grid focus returns to the text input
Tabstart input of a rangeMoves to the end input; the popup stays open
calendar keysfocus in the gridSee Calendar; Enter chooses; a complete choice closes and returns focus

Platform features

FeatureBaselineOutside the target
<input type="date">Baseline 2021A text input; validate the ISO value on the server
Popover API: popover, showPopover(), hidePopover()Baseline 2024The popup renders in place instead of in the top layer
CSS anchor positioning: anchor-name, anchor-scope, position-areaNot Baseline: Chrome 125 (anchor-scope 131) and Safari 26The popover popup is centered in the top layer
@media (scripting)Baseline 2023Without the query the native inputs and the owned field both show

Without JavaScript

@media (scripting: none) in date-picker.css hides the text inputs, the icon, the clear button and the popup and shows the native date inputs, with the platform’s picker, typing, validation and form submission. With scripting the rule is reversed, so the page never shows a control that cannot work.

date-picker.css (excerpt)
/* date-picker.css: with scripting the native inputs give way to the owned field; without it
 * the native date inputs are the field and the owned parts are not shown. */
@media (scripting: enabled) {
	:where([data-slean='date-picker'] > [data-slean-part='native']) {
		display: none;
	}
}

@media (scripting: none) {
	:where([data-slean='date-picker'] > :is([data-slean-part='input'], [data-slean-part='separator'],
			[data-slean-part='clear'], [data-slean-part='icon'], [data-slean-part='popup'])) {
		display: none;
	}
}

Server rendering

Render the native input’s value, the text input’s value with formatDisplay(), aria-expanded as false, a closed popup and the calendar with calendarMonth() from the same value. formatDisplay() and dateFormat() are pure and run on the server.

Before hydration

Before the behaviors load, the text input can be typed into but nothing opens and the text is not read back; the native value is the server’s. The registration attaches no per-root state, so the first interaction after hydration creates the controller.

Styling

control.css draws the field: border, hover, focus halo, sizes (data-size), status (data-status), the clear button and the icon masks, and the popup surface. date-picker.css adds the separator arrow, the bar under the focused end of a range, the footer, the Today and preset buttons, and the scripting swap; calendar.css draws the header and the grid. The tokens of control.css and date-picker.css:

TokenDefault (light)Applies to
--slean-space-10.25remgap, padding-inline, margin-block, padding, scroll-padding-block, padding-block
--slean-control-height-md2.25remmin-block-size
--slean-space-30.75rempadding-inline, padding
--slean-space-20.5rempadding-inline, max-inline-size, gap, padding-block, padding
--slean-control-bordervar(--slean-border-strong)border
--slean-radius-md0.625remborder-radius
--slean-control-bgvar(--slean-surface)background
--slean-fgvar(--slean-neutral-12)color
--slean-text-sm0.875remfont-size
--slean-leading1.5line-height
--slean-duration-fast100mstransition
--slean-easecubic-bezier(0.2, 0, 0, 1)transition
--slean-control-border-hovervar(--slean-accent)border-color
--slean-control-border-focusvar(--slean-accent)border-color
--slean-control-ring0 0 0 3px color-mix(in oklch, var(--slean-accent) 22%, transparent)box-shadow
--slean-dangeroklch(55% 0.2 25)border-color
--slean-control-ring-danger0 0 0 3px color-mix(in oklch, var(--slean-danger) 22%, transparent)box-shadow
--slean-warningoklch(76% 0.16 80)border-color
--slean-control-ring-warning0 0 0 3px color-mix(in oklch, var(--slean-warning) 28%, transparent)box-shadow
--slean-control-border-disabledvar(--slean-border)border-color
--slean-control-bg-disabledvar(--slean-muted)background
--slean-fg-mutedvar(--slean-neutral-11)color
--slean-control-height-sm2remmin-block-size
--slean-radius-sm0.375remborder-radius
--slean-control-height-lg2.75remmin-block-size
--slean-space-41rempadding-inline, padding-block
--slean-text-md1remfont-size
--slean-control-iconvar(--slean-fg-muted)color
--slean-icon-chevron-downurl("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4 6l4 4 4-4' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E")mask
--slean-duration-normal160mstransition
--slean-icon-clearurl("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cdefs%3E%3Cmask id='m'%3E%3Crect width='16' height='16' fill='white'/%3E%3Cpath d='M6 6l4 4m0-4l-4 4' stroke='black' stroke-width='1.5' stroke-linecap='round'/%3E%3C/mask%3E%3C/defs%3E%3Ccircle cx='8' cy='8' r='6.5' mask='url(%23m)'/%3E%3C/svg%3E")mask-image
--slean-bordervar(--slean-neutral-6)border, border-block-start
--slean-popup-radiusvar(--slean-radius-md)border-radius
--slean-surfaceoklch(100% 0 0)background
--slean-popup-shadowvar(--slean-shadow-lg)box-shadow
--slean-icon-checkurl("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M3.5 8.5l3 3 6-7' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E")mask
--slean-option-activevar(--slean-muted)background
--slean-option-selectedvar(--slean-accent-soft)background
--slean-option-selected-fgvar(--slean-accent-soft-fg)color, box-shadow
--slean-font-weight-semibold600font-weight
--slean-text-xs0.75remfont-size
--slean-font-weight-medium500font-weight
--slean-icon-calendarurl("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cg stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'%3E%3Crect x='2.5' y='3.5' width='11' height='10' rx='1.5'/%3E%3Cpath d='M2.5 6.75h11M5.5 2v3M10.5 2v3'/%3E%3C/g%3E%3C/svg%3E")mask-image
--slean-icon-arrow-righturl("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M2.5 8h11M10 4.5L13.5 8 10 11.5' 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)box-shadow, color, border-color
--slean-accent-softoklch(95% 0.03 258)border, background
--slean-accent-soft-fgoklch(42% 0.17 258)color
--slean-accent-hoveroklch(49% 0.19 258)color
--slean-color-schemelightcolor-scheme

State selectors the stylesheet targets, all from the platform or ARIA: ::placeholder, :dir(rtl), :disabled, :empty, :focus, :focus-visible, :focus-within, :hover, :placeholder-shown, :popover-open, :user-invalid, [aria-disabled="true"], [aria-expanded="true"], [aria-invalid="true"], [hidden], [popover], [role="listbox"], [role="option"].

Variant attributes: data-status (error, warning); data-size (sm, lg); data-slean-active; data-slean-selected.

Compatibility notes

Popover is Baseline 2024; CSS anchor positioning (Chrome 125, Safari 26) places the popup under the field, and without it the popover popup is centered in the top layer. @media (scripting) is Baseline 2023. The native date input is Baseline 2021; where it is missing it degrades to a text input, so validate the ISO value on the server.

Examples

Range picker

Two native inputs hold the start and the end; two text inputs show them with an arrow between them. The calendar is in range mode with two months side by side: the first choice opens the range, the days under the pointer preview it, and the second choice writes both inputs and closes the popup. Tab moves from the start to the end while the popup stays open. The presets in the footer set a range in one press.

Stay

The bound values: 2026-09-10 to 2026-09-18

stay.svelte
<script lang="ts">
	import { onMount } from 'svelte';
	import { addDays, addMonths, today } from '@svelte-lean/primitives/calendar';
	import { formatDisplay } from '@svelte-lean/primitives/date-picker';
	import Month from './Month.svelte';

	const FORMAT = 'MM/DD/YYYY';
	let start = $state('2026-09-10');
	let end = $state('2026-09-18');

	// Presets are relative to the reader's today, so a prerendered page computes them again in
	// the browser; each preset is data-slean-value="start/end".
	let now = $state('2026-09-28');
	onMount(() => (now = today()));
	const month = $derived(now.slice(0, 7));
	const presets = $derived([
		['Last 7 days', `${addDays(now, -6)}/${now}`],
		['Last 30 days', `${addDays(now, -29)}/${now}`],
		['This month', `${month}-01/${addDays(`${addMonths(month, 1)}-01`, -1)}`]
	]);
</script>

<span id="stay-label">Stay</span>
<div id="stay" data-slean="date-picker" data-slean-format={FORMAT}>
	<input id="stay-start" type="date" name="from" data-slean-part="native" bind:value={start} />
	<input id="stay-end" type="date" name="to" data-slean-part="native" bind:value={end} />
	<input type="text" role="combobox" aria-haspopup="dialog" aria-expanded="false"
		aria-controls="stay-popup" aria-label="Start date" autocomplete="off" placeholder="Start date"
		value={formatDisplay(start, FORMAT)} data-slean-part="input" />
	<span data-slean-part="separator" aria-hidden="true"></span>
	<input type="text" role="combobox" aria-haspopup="dialog" aria-expanded="false"
		aria-controls="stay-popup" aria-label="End date" autocomplete="off" placeholder="End date"
		value={formatDisplay(end, FORMAT)} data-slean-part="input" />
	<button type="button" tabindex="-1" aria-label="Clear" data-slean-part="clear"></button>
	<span data-slean-part="icon" aria-hidden="true"></span>
	<div id="stay-popup" popover="manual" role="dialog" aria-labelledby="stay-label"
		data-slean-part="popup">
		<div data-slean="calendar" data-slean-mode="range" data-slean-month="2026-09"
			data-slean-value="2026-09-10/2026-09-18" data-slean-input="stay-start stay-end">
			<Month id="stay-cal" month="2026-09" months={2}
				options={{ mode: 'range', value: '2026-09-10/2026-09-18' }} />
		</div>
		<div data-slean-part="footer">
			{#each presets as [label, value] (label)}
				<button type="button" data-slean-part="preset" data-slean-value={value}>{label}</button>
			{/each}
		</div>
	</div>
</div>

Display format

The text input shows the date in data-slean-format (YYYY, MM, DD, M, D and literal separators), else in the numeric pattern of the nearest lang from dateFormat(). The native input keeps the ISO value whatever the text shows, so the form submits 2026-09-28. Render the text on the server with formatDisplay(value, format), the function the behavior writes it with.

format and locale
<!-- The display format is data-slean-format, else the numeric pattern of the nearest lang:
     dateFormat('tr') is DD.MM.YYYY, dateFormat('en-US') MM/DD/YYYY, dateFormat('sv') YYYY-MM-DD. -->
<div lang="tr">
	<div id="due" data-slean="date-picker">…</div>
</div>

<!-- Typing is read in the order of the format's fields; any run of non-digits separates them,
     so 28.9.2026, 28/09/2026 and 2026-09-28 all read as 2026-09-28 in DD.MM.YYYY. -->

Testing

Source