Primitives Date and time
Date picker
A date field with a calendar popup: a formatted text input, a clear button and a calendar icon inside one field, and a popup with the month grid and a footer; a range is two inputs and two months. A native date input underneath keeps the ISO value, the form name and min and max, and is the field when JavaScript is off. Tier 2.
- Tier
- 2 · Lazy scoped controller
- Behavior JS
- 4912 B brotli · 5364 B gzip ·
packages/· methodprimitives/ artifacts/ size.json - Platform features
<input type="date">,role="combobox",popover="manual",anchor-name,@media (scripting),Intl.DateTimeFormat- Shared listeners
- click, keydown, input, change, pointerdown, focusout, toggle
- 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
On this page
Example
September 2026
| Mon | Tue | Wed | Thu | Fri | Sat | Sun |
|---|---|---|---|---|---|---|
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><script lang="ts">
// With @svelte-lean/vite the register imports are injected for the static
// data-slean="date-picker" and data-slean="calendar" markers.
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';
import { formatDisplay } from '@svelte-lean/primitives/date-picker';
import Month from './Month.svelte'; // the calendar header and grid, rendered once
const FORMAT = 'MM/DD/YYYY';
// The native input holds the ISO value; bind:value follows the input and change events.
let due = $state('2026-09-28');
</script>
<label for="due-text">Due date</label>
<div id="due" data-slean="date-picker" data-slean-format={FORMAT}>
<input id="due-date" type="date" name="due" data-slean-part="native" bind:value={due} />
<input
id="due-text"
type="text"
role="combobox"
aria-haspopup="dialog"
aria-expanded="false"
aria-controls="due-popup"
autocomplete="off"
placeholder="Select date"
value={formatDisplay(due, 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="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">
<Month id="due-cal" month="2026-09" options={{ value: '2026-09-28' }} />
</div>
<div data-slean-part="footer">
<button type="button" data-slean-part="today">Today</button>
</div>
</div>
</div>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/primitivespnpm add @svelte-lean/primitivesyarn add @svelte-lean/primitivesbun add @svelte-lean/primitivesimport '@svelte-lean/primitives/date-picker/register';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
| 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 and separators); styles: data-size, data-status. |
| native | <input type="date" name="…"> | native | yes | One, 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="…"> | 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 | Shown on hover or focus while there is a value; empties every native input. |
| icon | <span aria-hidden="true"> | icon | no | The calendar icon, a mask from --slean-icon-calendar; a click on it opens or closes. |
| popup | <div popover="manual" role="dialog" aria-label="…"> | popup | yes | Holds the calendar (render it once) and the footer. |
| footer | any element in the popup | footer | no | Holds the today and preset buttons. |
| today | <button type="button"> | today | no | Chooses today and closes (single mode). |
| 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. |
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"witharia-haspopup="dialog",aria-expanded(written) andaria-controlsnaming the popup; it has a label (oraria-labelfor 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
minandmaxupdates 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-hiddenon the icon); the calendar’s own grid semantics apply inside the popup.
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 |
| 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 |
Platform features
| Feature | Baseline | Outside the target |
|---|---|---|
<input type="date"> | Baseline 2021 | A text input; validate the ISO value on the server |
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 | Not Baseline: Chrome 125 (anchor-scope 131) and Safari 26 | The popover popup is centered in the top layer |
@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, 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: 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:
| Token | Default (light) | Applies to |
|---|---|---|
--slean-space-1 | 0.25rem | gap, padding-inline, margin-block, padding, scroll-padding-block, padding-block |
--slean-control-height-md | 2.25rem | min-block-size |
--slean-space-3 | 0.75rem | padding-inline, padding |
--slean-space-2 | 0.5rem | padding-inline, max-inline-size, gap, padding-block, padding |
--slean-control-border | var(--slean-border-strong) | border |
--slean-radius-md | 0.625rem | border-radius |
--slean-control-bg | var(--slean-surface) | background |
--slean-fg | var(--slean-neutral-12) | color |
--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 | var(--slean-accent) | border-color |
--slean-control-border-focus | var(--slean-accent) | border-color |
--slean-control-ring | 0 0 0 3px color-mix(in oklch, var(--slean-accent) 22%, transparent) | box-shadow |
--slean-danger | oklch(55% 0.2 25) | border-color |
--slean-control-ring-danger | 0 0 0 3px color-mix(in oklch, var(--slean-danger) 22%, transparent) | box-shadow |
--slean-warning | oklch(76% 0.16 80) | border-color |
--slean-control-ring-warning | 0 0 0 3px color-mix(in oklch, var(--slean-warning) 28%, transparent) | box-shadow |
--slean-control-border-disabled | var(--slean-border) | border-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 | 0.375rem | border-radius |
--slean-control-height-lg | 2.75rem | min-block-size |
--slean-space-4 | 1rem | padding-inline, padding-block |
--slean-text-md | 1rem | font-size |
--slean-control-icon | var(--slean-fg-muted) | color |
--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-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 |
--slean-popup-radius | var(--slean-radius-md) | border-radius |
--slean-surface | oklch(100% 0 0) | background |
--slean-popup-shadow | var(--slean-shadow-lg) | box-shadow |
--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-selected | var(--slean-accent-soft) | background |
--slean-option-selected-fg | var(--slean-accent-soft-fg) | color, box-shadow |
--slean-font-weight-semibold | 600 | font-weight |
--slean-text-xs | 0.75rem | font-size |
--slean-font-weight-medium | 500 | font-weight |
--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-image |
--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-accent | oklch(54% 0.19 258) | box-shadow, color, border-color |
--slean-accent-soft | oklch(95% 0.03 258) | border, background |
--slean-accent-soft-fg | oklch(42% 0.17 258) | color |
--slean-accent-hover | oklch(49% 0.19 258) | color |
--slean-color-scheme | light | color-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.
September 2026
October 2026
| Mon | Tue | Wed | Thu | Fri | Sat | Sun |
|---|---|---|---|---|---|---|
| Mon | Tue | Wed | Thu | Fri | Sat | Sun |
|---|---|---|---|---|---|---|
The bound values: 2026-09-10 to 2026-09-18
<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.
<!-- 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
packages/primitives/src/date-picker/contract.mdthe test cases of the contract: tests/date-picker.test.ts (happy-dom) and a Playwright spec on the playground’s /primitives/date-picker pageapps/playground/src/routes/primitives/date-picker/+page.sveltethe playground fixtures: a date in DD.MM.YYYY, an empty picker and a range with presetsapps/playground/tests/primitives/nested.spec.tsa date picker inside a modal dialog: the popup above the dialog, keyboard and pointer choices, Escape closing the popup before the dialogapps/playground/tests/stress/memory.spec.ts200 open/close cycles through the grid: heap, DOM nodes and listeners back at baseline, one scoped listener per openfixtures/date-picker-onlyconsumer build: the date picker and calendar registrations ship with one kernel; tabs, menu, listbox, combobox and select are absentpackages/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)apps/playground/tests/primitives/a11y.spec.tsaxe on every playground page and on open dialog, popover and menu states
Source
packages/primitives/src/date-picker/contract.mdthe contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behaviorpackages/primitives/src/date-picker/contract.tstyped constants: name, tier, base, parts, options, eventspackages/primitives/src/date-picker/behavior.tsthe behavior definition, openDatePicker(), closeDatePicker(), setDatePickerValue()packages/primitives/src/date-picker/format.tsdateFormat(), formatDisplay() and parseDisplay(), pure and SSR-safepackages/primitives/src/date-picker/register.tsthe registration modulepackages/primitives/src/date-picker/validate.tsdevelopment validation messagespackages/styles/css/control.cssthe control shell shared with the select and the comboboxpackages/styles/css/date-picker.cssthe optional stylesheet