DatePicker

Accessible single-date picker with a month grid and full keyboard navigation.

Live demo

01

Default 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
DatePicker.svelte
<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

03

What 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 root

Timezone-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
PropTypeDefaultDescription
valuestring''Bindable selected date as an ISO yyyy-mm-dd string.
minstring''Earliest selectable ISO date (inclusive).
maxstring''Latest selectable ISO date (inclusive).
todaystring''ISO date highlighted as today β€” passed in, not read from the clock.
localestring'en-GB'BCP-47 locale for month/weekday labels and first day of week.
placeholderstring'Select date'Trigger text shown when no date is selected.
labelstring'Choose date'Accessible name (aria-label) for the trigger button.
disabledbooleanfalseDisables the trigger; the popover cannot open.