DatePicker
Accessible single-date picker with a month grid and full keyboard navigation.
Live demo
01Default picker
Click to open, then drive it from the keyboard: arrows move by day, PageUp/PageDown change month, Enter or Space selects.
Selected: 2026-06-15
Bounded range
min and max grey out and disable days outside the second
quarter of 2026.
Selected: 2026-06-20
US English locale
A different locale changes the labels and starts the week on Sunday β the
grid layout follows the locale's first day of week.
Selected: 2026-07-04
Empty & disabled states
With no value the trigger shows the placeholder; the disabled picker
cannot open.
Implementation
02<script lang="ts">
import DatePicker from '$lib/components/DatePicker.svelte';
let date = $state('2026-06-15');
</script>
β
<DatePicker bind:value={date} min="2026-01-01" max="2026-12-31" today="2026-06-06" locale="en-GB" label="Choose a date" />DatePicker is a zero-dependency, accessible single-date picker. A trigger button opens a role="grid" month popover with a roving tabindex: arrow keys move by day, PageUp/PageDown change month, Home/End jump to week edges, Enter/Space select, Escape closes. The value is a bindable ISO yyyy-mm-dd string, min/max disable out-of-range days, and Intl.DateTimeFormat drives the locale-aware labels and first day of week. "Today" is passed in as an ISO string rather than read from the clock, keeping it deterministic and SSR-safe.
Logic explainer
03What Does It Do? (Plain English)
DatePicker is an accessible single-date selector. A trigger button shows the current value (or a placeholder); clicking it opens a popover containing a month grid. The reader can pick a day with the mouse or drive the whole calendar from the keyboard β arrow keys move day-by-day, PageUp/PageDown change month, Home/End jump to the edges of the focused week, and Enter or Space selects.
The value is a plain ISO yyyy-mm-dd string bound back to the parent with bind:value. Optional min / max bounds grey out and disable out-of-range days. Month names and weekday headers come from Intl.DateTimeFormat, so the calendar speaks whatever locale you hand it, and respects that locale's first day of week.
Crucially, the component never reads the wall clock to decide what "today" is β you pass today as an ISO string. That keeps it deterministic, server-renderable, and testable.
How It Works (Pseudo-Code)
parse value/min/max/today from ISO strings into Date objects (midday, to dodge DST)
focusDate = selected day, else today, else 1st of the current month
on trigger click:
if open -> close (restore focus to trigger)
else -> reset focusDate, open, then rAF-focus the roving day cell
derive view month from focusDate
build a 6-week grid:
find locale's first day of week
step back from the 1st to the start of its week
emit 42 DayCell { date, iso, inMonth, selectable: inRange(date) }
on grid keydown:
Arrow* -> moveFocus(Β±1 / Β±7); refocus active cell
PageUp/Down -> moveMonth(β1), clamping day to month length
Home/End -> jump to week start/end
Enter/Space -> selectDate(focusDate)
Escape -> close + restore focus
selectDate(d): if inRange -> value = toISO(d); close
close also fires on pointerdown outside root or focusout to a node outside rootTimezone-Safe Date Maths
Every Date is built from explicit (year, month, day, 12) parts rather than parsed from a string or read from Date.now(). Constructing at midday sidesteps daylight-saving transitions that can shunt a midnight date onto the previous day. parseISO also rejects impossible dates (e.g. 2026-02-31) by round-tripping the parts and checking they survived, instead of trusting JavaScript's silent roll-over. toISO pads back to yyyy-mm-dd, so what goes out matches what came in.
Roving Tabindex Grid
The month grid is a single composite widget, not 42 tab stops. Only the cell matching focusDate carries tabindex="0"; every other day button is tabindex="-1". After any keyboard move the component re-queries button[tabindex="0"] and calls .focus() on it inside a requestAnimationFrame, so DOM focus chases the logical cursor exactly once the grid has re-rendered. This is the WAI-ARIA grid pattern: one Tab lands you in the calendar, arrows move within it, Tab leaves it.
Locale-Aware Week Layout
firstDayOfWeek reads Intl.Locale(...).getWeekInfo() (or the weekInfo property on older engines) to learn whether the week starts on Monday, Sunday, or Saturday, converting Intl's 1β7 (MonβSun) into 0β6 (SunβSat). Weekday header names are generated by formatting seven consecutive real dates with { weekday: 'short' }, offset by that first day. If the API is missing the component falls back to Monday, matching en-GB and most of Europe.
State Flow Diagram
click trigger / Enter on trigger
closed ββββββββββββββββββββββββββββββββββββΆ open
β² β
β Escape / outside pointerdown / β Arrow/Page/Home/End
β focusout / selectDate β move focusDate, refocus cell
β βΌ
ββββββββββββββββββ select day βββββββ focused day (roving)
(value = toISO, close)Props Reference
| Prop | Type | Default | Description |
|---|---|---|---|
value |
string |
'' |
Bindable selected date as an ISO yyyy-mm-dd string. Use bind:value. |
min |
string |
'' |
Earliest selectable ISO date (inclusive). Days before it are disabled. |
max |
string |
'' |
Latest selectable ISO date (inclusive). Days after it are disabled. |
today |
string |
'' |
ISO date highlighted as "today" (aria-current="date"). Passed in, not read from the clock. |
locale |
string |
'en-GB' |
BCP-47 locale driving month/weekday labels and the first day of week. |
placeholder |
string |
'Select date' |
Trigger text shown when no date is selected. |
label |
string |
'Choose date' |
Accessible name (aria-label) for the trigger button. |
disabled |
boolean |
false |
Disables the trigger entirely; the popover cannot open. |
Edge Cases
| Case | Behaviour |
|---|---|
Invalid ISO in value (e.g. 2026-13-40) |
parseISO returns null; trigger shows the placeholder, no day is marked selected. |
Impossible date like 2026-02-31 |
Rejected by the round-trip check in parseISO; treated as no value. |
value outside [min, max] |
The string is still shown, but the day is unselectable, so the reader cannot re-pick it until in range. |
| PageUp/Down from the 31st into a shorter month | Day is clamped to the target month's last day (31 Jan β 28/29 Feb). |
getWeekInfo unavailable (older engine) |
Falls back to Monday as the first day of week. |
| Click or focus leaves the widget while open | Popover closes without stealing focus back to the trigger. |
prefers-reduced-motion |
Popover pop-in animation and all transitions are disabled. |
Empty min/max/today |
Treated as unset β no bounds applied, no "today" highlight. |
Dependencies
Zero external dependencies. Intl.DateTimeFormat and Intl.Locale are platform built-ins. All styling is scoped, with light/dark via CSS custom properties and @media (prefers-color-scheme: dark).
File Structure
src/lib/components/DatePicker.svelte # component (trigger + popover + grid)
src/lib/components/DatePicker.md # this document
src/lib/components/DatePicker.test.ts # vitest + @testing-library/svelte
src/routes/datepicker/+page.svelte # demo page (ComponentPageShell)API
04| Prop | Type | Default | Description |
|---|---|---|---|
value | string | '' | Bindable selected date as an ISO yyyy-mm-dd string. |
min | string | '' | Earliest selectable ISO date (inclusive). |
max | string | '' | Latest selectable ISO date (inclusive). |
today | string | '' | ISO date highlighted as today β passed in, not read from the clock. |
locale | string | 'en-GB' | BCP-47 locale for month/weekday labels and first day of week. |
placeholder | string | 'Select date' | Trigger text shown when no date is selected. |
label | string | 'Choose date' | Accessible name (aria-label) for the trigger button. |
disabled | boolean | false | Disables the trigger; the popover cannot open. |