# Date picker

> Tier 2 date field over a native date input: a formatted text input, clear button and calendar popup with Today; a range picker with two months and presets; the native input without JavaScript.

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

A date field with a calendar popup, for single dates and ranges; a native date input underneath keeps the value and the form.

```svelte
<script lang="ts">
	import '@svelte-lean/styles/control.css';
	import '@svelte-lean/styles/calendar.css';
	import '@svelte-lean/styles/date-picker.css';
	import { datePickerParts, formatDisplay } from '@svelte-lean/primitives/date-picker';

	const FORMAT = 'MM/DD/YYYY';
	const p = datePickerParts('due');
	// The native input holds the ISO value; bind:value follows its input and change events.
	let due = $state('2026-09-28');
</script>

<label {...p.label}>Due date</label>
<div id="due" data-slean="date-picker" data-slean-format={FORMAT} data-slean-today data-slean-clearable>
	<input {...p.native} name="due" bind:value={due} />
	<input {...p.input} placeholder="Select date" value={formatDisplay(due, FORMAT)} />
</div>
<p>Value: <code>{due || 'empty'}</code></p>
```

Click the field or type a date such as 10/2/2026; ArrowDown moves into the calendar, Enter chooses, Escape closes; the month and year selects jump. The popup, the calendar and the Today button are created on the first open.

## Examples

### Range picker

```svelte
<script lang="ts">
	import { addDays, today } from '@svelte-lean/primitives/calendar';
	import { datePickerParts, formatDisplay } from '@svelte-lean/primitives/date-picker';

	const FORMAT = 'MM/DD/YYYY';
	const p = datePickerParts('stay', { range: true });
	let start = $state('2026-09-10');
	let end = $state('2026-09-18');
	const now = today();
	const presets = [
		{ label: 'Last 7 days', value: `${addDays(now, -6)}/${now}` },
		{ label: 'Last 30 days', value: `${addDays(now, -29)}/${now}` }
	];
</script>

<label {...p.label}>Stay</label>
<div id="stay" data-slean="date-picker" data-slean-format={FORMAT} data-slean-clearable>
	<input {...p.native} name="from" bind:value={start} />
	<input {...p.nativeEnd} name="to" bind:value={end} />
	<input {...p.input} placeholder="Check-in" value={formatDisplay(start, FORMAT)} />
	<span {...p.separator}></span>
	<input {...p.inputEnd} aria-label="Check-out" placeholder="Check-out" value={formatDisplay(end, FORMAT)} />
	<!-- Presets are application data: write the popup; the calendar is added to it on the first open. -->
	<div {...p.popup}>
		<div data-slean-part="footer">
			{#each presets as preset (preset.label)}
				<button type="button" data-slean-part="preset" data-slean-value={preset.value}>{preset.label}</button>
			{/each}
		</div>
	</div>
</div>
```

Two native inputs make a range: two months side by side, the first choice opens the range, the second closes it. The presets live in an authored popup.

### Presets beside the calendar, an empty range

```svelte
<script lang="ts">
	import { addDays, today } from '@svelte-lean/primitives/calendar';
	import { dateFormat, datePickerParts, formatDisplay } from '@svelte-lean/primitives/date-picker';

	const FORMAT = dateFormat('en-US', { month: 'short' }); // MMM D, YYYY
	const p = datePickerParts('created', { range: true });
	let start = $state('');
	let end = $state('');
	// Presets are application data, computed on the clock the application chooses. "/" is the
	// empty range: that preset clears the field and is pressed while it is empty.
	const now = today();
	const presets = [
		{ label: 'All time', value: '/' },
		{ label: 'Today', value: `${now}/${now}` },
		{ label: 'Yesterday', value: `${addDays(now, -1)}/${addDays(now, -1)}` },
		{ label: 'Last 7 days', value: `${addDays(now, -6)}/${now}` },
		{ label: 'Last 30 days', value: `${addDays(now, -29)}/${now}` },
		{ label: 'This month', value: `${now.slice(0, 7)}-01/${now}` }
	];
</script>

<div lang="en-US">
	<label {...p.label}>Created</label>
	<!-- data-slean-empty: one label while both ends are empty and focus is elsewhere (CSS). -->
	<div id="created" data-slean="date-picker" data-slean-format={FORMAT} data-slean-empty="All time" data-slean-clearable>
		<input {...p.native} name="since" bind:value={start} />
		<input {...p.nativeEnd} name="until" bind:value={end} />
		<input {...p.input} placeholder="Start date" value={formatDisplay(start, FORMAT, 'en-US')} />
		<span {...p.separator}></span>
		<input {...p.inputEnd} aria-label="End date" placeholder="End date" value={formatDisplay(end, FORMAT, 'en-US')} />
		<!-- A presets part is a column beside the calendar; the preset that is the value gets
		     aria-pressed="true" while the popup is open. -->
		<div {...p.popup}>
			<div role="group" aria-label="Shortcuts" data-slean-part="presets">
				{#each presets as preset (preset.label)}
					<button type="button" data-slean-part="preset" data-slean-value={preset.value}>{preset.label}</button>
				{/each}
			</div>
		</div>
	</div>
</div>
```

A presets part lists the presets in a column beside the two months; the preset that is the value is pressed (aria-pressed) while the popup is open. While both ends are empty and focus is elsewhere, data-slean-empty shows one label instead of two placeholders; focus brings the placeholders back for typing.

### Minimum, maximum and off days

```svelte
<script lang="ts">
	import { datePickerParts, formatDisplay } from '@svelte-lean/primitives/date-picker';

	const p = datePickerParts('visit');
	let visit = $state('');
</script>

<!-- min and max are the native input's; data-slean-offdays refuses Saturdays and Sundays,
     data-slean-weekstart="0" starts the weeks on Sunday. -->
<label {...p.label}>Appointment</label>
<div
	id="visit"
	data-slean="date-picker"
	data-slean-format="MM/DD/YYYY"
	data-slean-offdays="0 6"
	data-slean-weekstart="0"
>
	<input {...p.native} name="visit" min="2026-10-05" max="2026-11-20" bind:value={visit} />
	<input {...p.input} placeholder="Weekdays, Oct 5 to Nov 20" value={formatDisplay(visit, 'MM/DD/YYYY')} />
</div>
```

The native input's min and max bound the calendar; data-slean-offdays refuses weekends.

### Month and year selects

```svelte
<script lang="ts">
	import { datePickerParts, formatDisplay } from '@svelte-lean/primitives/date-picker';

	const p = datePickerParts('birth');
	let birth = $state('1990-09-15');
</script>

<!-- The generated calendar has month and year selects; the native input's min and max bound
     the years (1900 to 2099 without them). -->
<label {...p.label}>Birth date</label>
<div id="birth" data-slean="date-picker" data-slean-format="MM/DD/YYYY">
	<input {...p.native} name="birth" min="1926-01-01" max="2026-12-31" bind:value={birth} />
	<input {...p.input} placeholder="Select date" value={formatDisplay(birth, 'MM/DD/YYYY')} />
</div>
```

The generated calendar's month and year selects jump to any month: 1990 is one choice in the year select away. The years run from the native input's min to its max.

### Date and time

```svelte
<script lang="ts">
	import { datePickerParts, formatDisplay } from '@svelte-lean/primitives/date-picker';

	// { time: true }: the native input is type="datetime-local", value YYYY-MM-DDTHH:mm.
	const FORMAT = 'MM/DD/YYYY hh:mm A';
	const p = datePickerParts('meet', { time: true });
	let meet = $state('2026-09-28T14:30');
</script>

<!-- The popup adds <input type="time" data-slean-part="time"> to its footer; step="900" moves the
     time by 15 minutes. Without data-slean-format the locale's date and time pattern is used. -->
<label {...p.label}>Meeting</label>
<div id="meet" data-slean="date-picker" data-slean-format={FORMAT} data-slean-today>
	<input {...p.native} name="meet" step="900" bind:value={meet} />
	<input {...p.input} placeholder="Select date and time" value={formatDisplay(meet, FORMAT)} />
</div>
```

A datetime-local native input holds a date and a time: the popup adds the platform's time input to its footer, a chosen day keeps the time, and Enter in the time closes. Type 10/02/2026 9:30 am in the field.

### Display format

```svelte
<script lang="ts">
	import { dateFormat, datePickerParts, formatDisplay } from '@svelte-lean/primitives/date-picker';

	// No data-slean-format: the nearest lang decides, here en-GB, DD/MM/YYYY. The calendar's
	// titles, weekday names and button names follow the same lang.
	const DMY = dateFormat('en-GB');
	const p = datePickerParts('invoice');
	let invoice = $state('2026-10-04');
</script>

<div lang="en-GB">
	<label {...p.label}>Invoice date</label>
	<div id="invoice" data-slean="date-picker" data-slean-today>
		<input {...p.native} name="invoice" bind:value={invoice} />
		<input {...p.input} placeholder="DD/MM/YYYY" value={formatDisplay(invoice, DMY)} />
	</div>
	<p>The form submits <code>{invoice || 'nothing'}</code>.</p>
</div>
```

The text follows the nearest lang; the native input keeps the ISO value. Typing 15.10.2026 reads as 15 October; the calendar's names are British English.

### Month names

```svelte
<script lang="ts">
	import { dateFormat, datePickerParts, formatDisplay } from '@svelte-lean/primitives/date-picker';

	// The locale's medium date: MMM D, YYYY for en-US, D MMM YYYY for tr and en-GB,
	// D. MMM YYYY for de. MMMM is the long name.
	const FORMAT = dateFormat('en-US', { month: 'short' });
	const p = datePickerParts('ship');
	let ship = $state('2026-10-05');
</script>

<!-- The behavior writes and reads the month names in the nearest lang; the server renders the
     same text with formatDisplay(value, format, locale). -->
<div lang="en-US">
	<label {...p.label}>Ship date</label>
	<div id="ship" data-slean="date-picker" data-slean-format={FORMAT}>
		<input {...p.native} name="ship" bind:value={ship} />
		<input {...p.input} placeholder="Select date" value={formatDisplay(ship, FORMAT, 'en-US')} />
	</div>
</div>
```

MMM and MMMM are the month's short and long name in the nearest lang; dateFormat() with month: 'short' is the locale's medium date. Typing reads a month name in any case, short, long or begun: oct 12 2026, October 12, 2026 and 10/12/2026 all read as 12 October.

### Looks

```svelte
<!-- The field shell's looks (ADR 0011 §3.3): data-variant outlined (default) | filled | standard,
     data-size small | medium (default) | large, data-color on the field or an ancestor. -->
<div id="from" data-slean="date-picker" data-variant="filled" data-size="small">…</div>
<div id="until" data-slean="date-picker" data-variant="standard">…</div>
<div id="launch" data-slean="date-picker" data-size="large" data-color="secondary">…</div>
```

Outlined by default; filled and standard as in MUI. The color is the focus line and the calendar's selection.

### Disabled

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

	const p = datePickerParts('closed');
</script>

<!-- disabled on the native input disables the whole field: nothing opens, nothing is generated. -->
<label {...p.label}>Closing date</label>
<div id="closed" data-slean="date-picker" data-slean-format="MM/DD/YYYY">
	<input {...p.native} name="closed" value="2026-12-31" disabled />
	<input {...p.input} placeholder="Select date" value="12/31/2026" disabled />
</div>
```

## Helper or plain markup

Two ways to write the same markup: with `datePickerParts()` or by hand. Both render the same HTML, which a test of this site compares; use either.

`With datePickerParts()`

```svelte
<script lang="ts">
	import { datePickerParts } from '@svelte-lean/primitives/date-picker';
	const d = datePickerParts('due');
</script>

<label {...d.label}>Due date</label>
<div id="due" data-slean="date-picker">
	<input {...d.native} name="due" />
	<input {...d.input} placeholder="Select date" />
</div>
```

`Plain markup`

```html
<label id="due-label" for="due-input">Due date</label>
<div id="due" data-slean="date-picker">
	<input
		type="date"
		id="due-native"
		name="due"
		aria-labelledby="due-label"
		data-slean-part="native"
	/>
	<input
		type="text"
		id="due-input"
		role="combobox"
		aria-haspopup="dialog"
		aria-expanded="false"
		aria-controls="due-popup"
		autocomplete="off"
		placeholder="Select date"
		data-slean-part="input"
	/>
</div>
```

### What the helper does

- One id in, every part id out: `datePickerParts('due')` writes `due-label`, `due-native` and `due-input`, and the `aria-controls` that names `due-popup`, the popup the behavior creates on the first open.
- Strings only: no handler, no state, no instance. The server and the browser render the same attributes, so hydration has nothing to patch.
- `range: true` adds the end inputs (`-native-1`, `-input-1`) and the separator; `time: true` makes the native input `datetime-local`.

### Points to watch

- Spread `d.native` and `d.input` on two `<input>` elements: the helper writes their `type`.
- It registers nothing and imports no stylesheet: the root's `id` and `data-slean="date-picker"` stay in your markup, where `@svelte-lean/vite` finds them.
- The value, the `name`, the placeholder and the formatted text stay in the template (`formatDisplay()` renders the text).
- Ids must be unique on the page. Hand-written markup works the same; the ids and the references between them are then yours to keep in step.

## Usage

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

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

`+layout.svelte or any client module`

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

`stylesheets`

```ts
import '@svelte-lean/styles/control.css';
import '@svelte-lean/styles/input.css';
import '@svelte-lean/styles/calendar.css';
import '@svelte-lean/styles/date-picker.css';
```

Write the root, the native date input (two for a range) and one text input per native input, with `datePickerParts(id)` for their attributes. Render the text with `formatDisplay(value, format)` and bind the native input with `bind:value`.

Month names: `MMM` and `MMMM` in the format are the month’s short and long name in the nearest `lang`, and `dateFormat(locale, { month: 'short' })` is the locale’s medium date (`MMM D, YYYY` for `en-US`, `D MMM YYYY` for `tr`). Render the text with `formatDisplay(value, format, locale)`.

For a date and a time, `datePickerParts(id, { time: true })` makes the native input `type="datetime-local"`: the popup gets a time input, the text a time (`HH:mm`, or `hh:mm A`), and `step` sets the minutes the time moves by.

Options on the root: `data-slean-format`, `data-slean-today` (a Today button, the locale’s word or the option’s value), `data-slean-clearable` (a clear button on first use), `data-slean-time` (the time input’s name), `data-slean-empty` (one label while the field is empty), `data-slean-weekstart` and `data-slean-offdays`; `min` and `max` are the native input’s, and bound the year select as well. For presets, write the popup (`p.popup`) with a footer of `preset` buttons under the calendar or a `presets` part beside it; the preset that is the value is pressed. For other labels or year buttons, write the calendar in it. Without JavaScript the native input is the field: for a table cell or a plain form, an `input` with `type="date"` is the Tier 0 path.

`datePickerParts(id, { labelledBy })` names the field by elements rendered elsewhere and `{ label }` by text, instead of an `<id>-label` label. The generated time inputs of a range are named after their text inputs (“Check-in, Time”).

## Anatomy

| Part | Element | data-slean-part | Required | Notes |
| --- | --- | --- | --- | --- |
| root | `<div id="…" data-slean="date-picker">` | – | yes | The field. Options: data-slean-format (YYYY, MM, DD, M, D, MMM and MMMM for the month’s name in the nearest lang, with a time HH, hh, H, h, mm, ss, A, and separators), data-slean-today, data-slean-clearable, data-slean-time (the time inputs’ name), data-slean-empty (one label while the field is empty and focus is elsewhere), data-slean-weekstart, data-slean-offdays; styles: data-variant="outlined\|filled\|standard", data-size, data-color, data-status. The calendar icon is its ::after. |
| native | `<input type="date" name="…">` | `native` | yes | One, or two for a range (two make the range): the ISO value, min and max; named by the calendar’s data-slean-input. type="datetime-local" (datePickerParts(id, { time: true })) holds a date and a time. Shown only without scripting. |
| input | `<input type="text" role="combobox" aria-haspopup="dialog" aria-controls="…">` | `input` | yes | One per native input: the formatted date, a placeholder and a name. Server-render its value with formatDisplay(). |
| separator | `<span aria-hidden="true">` | `separator` | no | Between the two text inputs of a range; an arrow icon, mirrored under RTL. |
| clear | `<button type="button" tabindex="-1" aria-label="…">` | `clear` | no | Generated on the first hover or focus with a value (data-slean-clearable); shown on hover or focus while there is a value; empties every native input. |
| popup | `<div popover="manual" role="dialog">` | `popup` | no | Generated on the first open, with the calendar. Write it (datePickerParts().popup) to hold a footer of presets or your own calendar. |
| footer | `any element in the popup` | `footer` | no | Holds the time inputs and the today and preset buttons; generated for the time or the Today button. |
| time | `<input type="time">` | `time` | no | One per datetime-local native input, generated first in the footer on the first open with the native step; edits the time of the value as it changes. A range names each after its text input. |
| today | `<button type="button">` | `today` | no | Chooses today and closes (single mode); generated with data-slean-today. |
| preset | `<button type="button" data-slean-value="YYYY-MM-DD/YYYY-MM-DD">` | `preset` | no | Sets the value, a date or a range, and closes; "/" is the empty field. While the popup is open, aria-pressed="true" on the preset that is the value and "false" on the others. |
| presets | `<div role="group" aria-label="…"> in the popup` | `presets` | no | A column of preset rows at the popup’s inline start, beside the calendar; the footer stays under the calendar. |

## Keyboard

| Key | When | Result |
| --- | --- | --- |
| `characters`/`Backspace`/`Delete` | focus in a text input | Native editing; a date that parses and respects min and max is written into the native input at once and the calendar follows |
| `ArrowDown` | focus in a text input | Opens the popup and moves focus to the chosen day, else today |
| `Enter` | text input, popup open | Commits the text (text that does not parse reverts) and closes |
| `Enter` | a time input | Closes (the time is already written); focus returns to the text input |
| `Escape` | popup open | Closes; from the grid focus returns to the text input |
| `Tab` | start input of a range | Moves to the end input; the popup stays open |
| `calendar keys` | focus in the grid | See Calendar; Enter chooses; a complete choice closes and returns focus |

## Styling

`control.css` draws the field: border, hover, focus halo, the outlined, filled and standard variants (`data-variant`), sizes (`data-size`), the color (`data-color`), status (`data-status`), the clear button and the popup surface. `date-picker.css` draws the calendar icon on the root’s `::after`, the separator arrow, the bar under the focused end of a range, the footer, the Today and preset buttons (the pressed preset, the `presets` column beside the calendar), the empty label on the root’s `::before`, and the scripting swap; `calendar.css` draws the header and the grid. The tokens of `control.css` and `date-picker.css`:

| Token | Default (light) | Applies to |
| --- | --- | --- |
| `--slean-space-1` | `calc(var(--slean-spacing) * 1)` | gap, padding-inline, margin-inline, max-block-size, margin-block, padding, scroll-padding-block, padding-block |
| `--slean-control-height-md` | `2.25rem` | min-block-size |
| `--slean-space-3` | `calc(var(--slean-spacing) * 3)` | padding-inline, padding |
| `--slean-space-2` | `calc(var(--slean-spacing) * 2)` | padding-inline, max-inline-size, gap, padding-block, padding |
| `--slean-control-border` | `var(--slean-border-strong)` | border, border-block-end-color |
| `--slean-field-radius` | `var(--slean-radius-md)` | border-radius, border-start-start-radius, border-start-end-radius |
| `--slean-control-bg` | `var(--slean-surface)` | background |
| `--slean-fg` | `var(--slean-neutral-12)` | color, border-block-end-color, background |
| `--slean-text-sm` | `0.875rem` | font-size |
| `--slean-leading` | `1.5` | line-height |
| `--slean-duration-fast` | `100ms` | transition |
| `--slean-ease` | `cubic-bezier(0.2, 0, 0, 1)` | transition |
| `--slean-control-border-hover` | `initial` | border-color |
| `--slean-color-main` | `var(--slean-primary-main)` | border-color, box-shadow, border-block-end-color, color, background |
| `--slean-error-main` | `oklch(52% 0.19 25)` | border-color, border-block-end-color, box-shadow |
| `--slean-control-ring-error` | `0 0 0 3px color-mix(in oklch, var(--slean-error-main) 22%, transparent)` | box-shadow |
| `--slean-warning-main` | `oklch(52% 0.125 55)` | border-color, border-block-end-color, box-shadow |
| `--slean-control-ring-warning` | `0 0 0 3px color-mix(in oklch, var(--slean-warning-main) 28%, transparent)` | box-shadow |
| `--slean-control-border-disabled` | `var(--slean-border)` | border-color, border-block-end-color |
| `--slean-control-bg-disabled` | `var(--slean-muted)` | background |
| `--slean-fg-muted` | `var(--slean-neutral-11)` | color |
| `--slean-control-height-sm` | `2rem` | min-block-size |
| `--slean-radius-sm` | `calc(var(--slean-radius) * 0.6)` | border-radius |
| `--slean-control-height-lg` | `2.75rem` | min-block-size |
| `--slean-space-4` | `calc(var(--slean-spacing) * 4)` | padding-inline, padding-block |
| `--slean-text-md` | `1rem` | font-size |
| `--slean-control-filled-bg` | `var(--slean-muted)` | background-color |
| `--slean-control-filled-bg-hover` | `var(--slean-muted-hover)` | background-color |
| `--slean-control-icon` | `var(--slean-fg-muted)` | color, background |
| `--slean-icon-chevron-down` | `url("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-normal` | `160ms` | transition |
| `--slean-icon-select` | `var(--slean-icon-chevron-down)` | mask-image |
| `--slean-icon-select-turn` | `180deg` | rotate |
| `--slean-icon-clear` | `url("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-border` | `var(--slean-neutral-6)` | border, border-block-start, border-inline-end |
| `--slean-popup-radius` | `var(--slean-radius-md)` | border-radius |
| `--slean-surface` | `oklch(100% 0 0)` | background |
| `--slean-popup-shadow` | `var(--slean-elevation-3)` | box-shadow |
| `--slean-option-min-height` | `var(--slean-control-height-sm)` | min-block-size |
| `--slean-option-padding-block` | `var(--slean-space-1)` | padding-block |
| `--slean-option-padding-inline` | `var(--slean-space-2)` | padding-inline |
| `--slean-option-check-size` | `1rem` | inline-size, block-size |
| `--slean-icon-check` | `url("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-active` | `var(--slean-muted)` | background |
| `--slean-option-active-fg` | `var(--slean-fg)` | color |
| `--slean-option-selected` | `var(--slean-primary-soft)` | background |
| `--slean-option-selected-fg` | `var(--slean-primary-soft-text)` | color |
| `--slean-option-selected-weight` | `var(--slean-font-weight-semibold)` | font-weight |
| `--slean-option-selected-ring` | `inset 0 0 0 1px var(--slean-option-selected-fg)` | box-shadow |
| `--slean-text-xs` | `0.75rem` | font-size |
| `--slean-font-weight-medium` | `500` | font-weight |
| `--slean-icon-plus` | `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 3.5v9M3.5 8h9' stroke='black' stroke-width='1.5' stroke-linecap='round' fill='none'/%3E%3C/svg%3E")` | mask |
| `--slean-icon-calendar` | `url("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 |
| `--slean-icon-arrow-right` | `url("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-color-scheme` | `light` | color-scheme |
| `--slean-color-soft` | `var(--slean-primary-soft)` | border, background |
| `--slean-color-soft-text` | `var(--slean-primary-soft-text)` | color |
| `--slean-color-contrast-text` | `var(--slean-primary-contrast-text)` | color |
| `--slean-color-dark` | `var(--slean-primary-dark)` | color |
| `--slean-muted` | `var(--slean-neutral-3)` | background |
| `--slean-radius-md` | `var(--slean-radius)` | border-radius |

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

Variant attributes: `data-status` (`warning`); `data-size` (`small`, `large`); `data-variant` (`filled`, `standard`); `data-slean-active`; `data-slean-selected`; `data-slean-new`; `data-slean-empty`.

## Under the hood

Runtime and size

- **Tier**: 2 · Lazy scoped controller
- **Behavior JS**: 9475 B brotli · 10358 B gzip · `packages/primitives/artifacts/size.json` · [method](https://svelte-lean.vercel.app/performance/methodology.md)
- **Platform features**: `<input type="date">`, `<input type="datetime-local">`, `<input type="time">`, `role="combobox"`, `popover="manual"`, `anchor-name`, `@media (scripting)`, `Intl.DateTimeFormat`, `Intl.RelativeTimeFormat`, `Intl.DisplayNames`
- **Shared listeners**: click, keydown, input, change, pointerdown, pointerover, focusin, focusout, toggle, reset
- **Per-instance listeners**: scoped to one interaction session
- **Lazy state**: WeakMap entry created on first interaction
- **Native base**: `<input type="date"> + role=combobox text input + [popover] + calendar`

Tier 2: a lazy scoped controller. Shared listeners (`click`, `keydown`, `input`, `change`, `pointerdown`, `pointerover`, `focusin`, `focusout`, `toggle` in the capture phase and `reset`) serve every date picker on the page; the register module also registers the calendar it generates. 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.

Why this implementation

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 calendar icon drawn by CSS and the whole field as the click target. The popup is hidden until it opens, so nothing assistive technology needs is missing from the server markup: the behavior creates the popup, the calendar and the footer on the first open (ADR 0011 §2.2), and a form with five date pickers renders no calendar until one opens.

### The browser owns

- the ISO value, min, max and step, form submission and validation of the native date (or datetime-local) inputs
- the time of a date and time: the segments of the native time input, in the user’s 12- or 24-hour clock
- the pickers of the month and year selects (native \<select\>)
- 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, the calendar icon drawn on the root and a clear button inside one control shell (control.css)
- datePickerParts(): the attributes of the parts from one id, the same on the server and in the browser
- the popup, its calendar with month and year selects, the time inputs of a date and time, the Today and clear buttons, created on first use and named in the page’s language
- formatDisplay(), parseDisplay() and dateFormat(): the display format from data-slean-format or the locale, month names (MMM, MMMM) included
- 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 or beside the calendar, the preset that is the value marked with aria-pressed
- the empty label (data-slean-empty): one label in place of the placeholders while the field is empty, drawn by CSS
- the lazy controller, development validation, date-picker.css and the contract

Accessibility

- Each text input is a `role="combobox"` with `aria-haspopup="dialog"`, `aria-expanded` (written) and `aria-controls` naming the popup, which is created with that id on the first open; it has a label (or `aria-label` for the end of a range) and keeps DOM focus while the pointer chooses.
- The popup is a `role="dialog"` named by the field’s label. ArrowDown moves focus to the chosen day (else today); Escape closes and returns focus to the text input; choosing closes and returns focus there. The month buttons and the month and year selects are named in the page’s language (`Intl.RelativeTimeFormat`, `Intl.DisplayNames`); the selects keep the platform’s keyboard.
- A date and a time: the time is the platform’s `<input type="time">` in the footer (named “Time” or `data-slean-time`), so its segments, the 12- or 24-hour clock and screen reader support are the browser’s; a chosen day keeps the popup open for it, and Enter in the time closes and returns focus to the text input.
- Presets are buttons: while the popup is open the one whose value is the field’s value has `aria-pressed="true"` and the others `"false"`. Write a `presets` part as a `role="group"` with a name (“Shortcuts”).
- The empty label of `data-slean-empty` is CSS generated content on the root, shown only while every text input shows its placeholder and focus is outside the field; browsers expose it as text before the inputs. A focused text input is announced with its own name and placeholder.
- 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 is pointer-only (`tabindex="-1"`, created on the first hover or focus with a value); keyboard users clear the text. The calendar’s grid semantics apply inside the popup.

Platform features

| Feature | Baseline | Outside the target |
| --- | --- | --- |
| `<input type="date">` | Baseline 2021 | A text input; validate the ISO value on the server |
| `<input type="datetime-local">, <input type="time">` | Baseline 2021 | A text input; validate the value on the server |
| `Intl.DisplayNames with type dateTimeField (the month and year selects’ names)` | Baseline 2022 | The selects are named Month and Year |
| `Popover API: popover, showPopover(), hidePopover()` | Baseline 2024 | The popup renders in place instead of in the top layer |
| `CSS anchor positioning: anchor-name, anchor-scope, position-area` | Baseline newly available since January 2026 (Chrome 125, Safari 26, Firefox 147; anchor-scope Chrome 131) | The popover popup is centered in the top layer |
| `Intl.RelativeTimeFormat (the generated buttons’ names)` | Baseline 2020 | Not applicable within the support policy |
| `@media (scripting)` | Baseline 2023 | Without the query the native inputs and the owned field both show |

Without JavaScript

`@media (scripting: none)` in `date-picker.css` hides the text inputs and the icon and shows the native date inputs, named by the same label, with the platform’s picker, typing, validation and form submission. Nothing is generated without JavaScript.

`date-picker.css (excerpt)`

```css
/* 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='popup'])) {
		display: none;
	}
}
```

Server rendering and hydration

Render the native input’s value, the text input’s value with `formatDisplay()` (with the locale when the format has month names) and `aria-expanded` as `false`; nothing of the popup is in the server HTML. The empty label is an attribute and CSS, so the server HTML shows it. `datePickerParts()`, `formatDisplay()` and `dateFormat()` are pure and run on the server.

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 and, on the first open, the popup.

Compatibility

Popover is Baseline 2024; CSS anchor positioning (Chrome 125, Safari 26, Firefox 147) 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.

Tests and source

- `packages/primitives/src/date-picker/contract.md` the test cases of the contract: tests/date-picker.test.ts (happy-dom) and a Playwright spec on the playground’s /primitives/date-picker page
- `apps/playground/src/routes/primitives/date-picker/+page.svelte` the playground fixtures: a date in DD.MM.YYYY with the generated popup, Today and clear buttons, an empty picker with off days, a range with presets in an authored popup, a birth date with an authored calendar and a meeting date and time (datetime-local) with the generated time input
- `apps/playground/tests/primitives/nested.spec.ts` a date picker inside a modal dialog: the popup above the dialog, keyboard and pointer choices, Escape closing the popup before the dialog
- `apps/playground/tests/stress/memory.spec.ts` 200 open/close cycles through the grid: heap, DOM nodes and listeners back at baseline, one scoped listener per open
- `fixtures/date-picker-only` consumer build of the short markup: the date picker registration, with the calendar it generates, ships with one kernel; tabs, menu, listbox, autocomplete and select are absent
- `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)
- `apps/playground/tests/primitives/a11y.spec.ts` axe on every playground page and on open dialog, popover and menu states

- `packages/primitives/src/date-picker/contract.md` the contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behavior
- `packages/primitives/src/date-picker/contract.ts` typed constants: name, tier, base, parts, options, events
- `packages/primitives/src/date-picker/behavior.ts` the behavior definition, openDatePicker(), closeDatePicker(), setDatePickerValue()
- `packages/primitives/src/date-picker/format.ts` dateFormat(), formatDisplay() and parseDisplay(), pure and SSR-safe
- `packages/primitives/src/date-picker/register.ts` the registration module
- `packages/primitives/src/date-picker/validate.ts` development validation messages
- `packages/styles/css/control.css` the control shell shared with the input, the select and the autocomplete
- `packages/styles/css/date-picker.css` the optional stylesheet
