jixoai-ui
the terminal design language
loading the style sheet…
Skip to content
docs · print medium: screen

registry:ui · Data Entry

date-picker — the zero-dep calendar popover

Form wave 2 — calendar popover over hand-rolled Date math (zero date libraries): single ISO string or {start,end} range with anchor/swap semantics, Monday-first grid, strict ISO parse/format/compare. Popover API panel (popover=auto + popovertarget) CSS-anchored under a select-style trigger; light dismiss/Escape/top-layer are native. Floating-surface law: ::after shadow layer (semi-transparent black/white) + @starting-style entry; variant='solid' | 'acrylic' | 'auto' (acrylic falls back to solid under prefers-reduced-transparency; the terminal bezel keeps its identity through the jx-surface fill props).

install — date-picker

$npx jixoai-ui add date-picker

https://ui.jixoai.com/r/date-picker.json

zero date libraries single + range modes ISO value · locale display one focus stop grid label[for] + aria wiring

overview

Overview

A select-style trigger over a native Popover API panel — zero date libraries, zero native input[type=date]: selection state, popover orchestration and calendar math are three orthogonal pieces.

The picker is deliberately not a native <input type="date">: a select-style trigger (the jx-html-input law's box) opens a Popover API panel — popover="auto" wired with popovertarget, so light dismiss, Escape, one-at-a-time and top-layer rendering are the browser's. The popover FAMILY is not in the chain; the panel speaks the shared jx-surface vocabulary and slides through the shared surface-motion kernel (one WAAPI-animated --jx-p number).

Three orthogonal pieces compose: the selection state lives here (single commits an ISO YYYY-MM-DD string; range binds a { start, end } pair with anchor / close / swap-when-backwards semantics; showTime widens the canonical value to a local wall-clock datetime that the grid and the TimeStepper edit one half each); the calendar fragment (nav + grid + keyboard cursor) is the embeddable Calendar, remounted per open so the view resets to the committed anchor; the ISO math lives in calendar-math.ts. Those two fragments are the composed consumers: they mount inside the field wrapper and inherit its density scope.

The quick-pick lane splits the same way — the LANE is the component's, the ENTRIES are yours — and a preset activation rides the exact grid-pick pipeline (commit + close). The committed value is canonical ISO forever; format and locale own the display only. All eight universal axes resolve on the field wrapper — density is the one axis that repaints (through its named rung), theme re-themes it partially, and six stamp-and-supply (per-axis below). The shared grammar lives on the universal props page.

usage

Usage

Bind value (single) or range (range); format is display-only, bounds are inclusive ISO days.

DatePicker usage
const deploy = $state('2026-08-24');
const sprint = $state({ start: '2026-08-10', end: '2026-08-16' });
const at = $state('2026-08-30T14:05'); // canonical datetime

<!-- single: commits ISO "YYYY-MM-DD" — format only changes the display -->
<DatePicker label="deploy date" bind:value={deploy} />
<DatePicker label="review" format="locale" bind:value={deploy} />

<!-- inclusive bounds; outside days render disabled -->
<DatePicker label="windowed" min="2026-08-01" max="2026-08-31" bind:value={deploy} />

<!-- range: first click anchors, second closes (backwards swaps) -->
<DatePicker label="sprint" mode="range" bind:range={sprint} />

<!-- presets: the lane is the component's, the entries are yours —
     plain dates in single mode, {start,end} pairs in range mode -->
<DatePicker
  label="quick picks"
  mode="range"
  presets={[
    { label: 'today', value: todayIso() },
    { label: 'last 7', value: { start: addDays(todayIso(), -6), end: todayIso() } },
  ]}
  bind:range={sprint}
/>

<!-- showTime (v1 single-mode only; range + time is a type error):
     the canonical value becomes "YYYY-MM-DDTHH:mm" local wall-clock —
     the grid mutates the date part, the stepper the time part -->
<DatePicker label="at" showTime format="locale" bind:value={at} />

<!-- isDisabled: your days wear the outside-day law (visible,
     not-allowed, uncommittable); the arrow walk skips them -->
<DatePicker label="weekdays only" isDisabled={(iso) => [0, 6].includes(new Date(iso + 'T00:00:00Z').getUTCDay())} bind:value={deploy} />

date-picker

A zero-dependency calendar popover over hand-rolled Date math — popover='auto' gives light dismiss and the top layer; single mode commits ISO 'YYYY-MM-DD'.

date-picker-usage.svelte svelte
<DatePicker
  label="deploy date"
  format="iso"
  bind:value
/>

date-picker

The calendar

No native <input type='date'>, no date library: the panel is a Popover API surface — popover='auto' wired with popovertarget, so light dismiss, Escape, one-at-a-time, and top-layer rendering are the browser's — over hand-rolled calendar math (leap years, month lengths, Monday-first grid offsets, strict ISO parse/format/compare). single commits 'YYYY-MM-DD'; range binds a start/end pair with anchor / close / swap-when-backwards semantics and a third click re-anchoring. The grid is one focus stop: ↑↓←→ walk the cursor across month boundaries (the view follows), Enter commits, Escape is native. format changes the display only — the value stays ISO forever.

date-picker · catalogue

bound value: 2026-08-24
display locale · value: —
outside days: opacity 0.3 · not-allowed
start: 2026-08-10 · end: 2026-08-16

audit date is required

error wiring: aria-invalid + dashed trigger
preset commit = grid pick: — → —
canonical value: 2026-08-30T14:05 · grid keeps the time, stepper keeps the day
weekend cells: not-allowed paint, arrow walk skips them
date-picker-catalog-demo.svelte svelte
<script lang="ts">
  import DatePicker from '@ui/date-picker.svelte';
  import type { DatePickerRange } from '@ui/date-picker.svelte';
  import { addDays, todayIso } from '@ui/date-picker/calendar-math';

  let deployDate = $state('2026-08-24');
  let localeDate = $state<string | undefined>(undefined);
  let windowedDate = $state<string | undefined>(undefined);
  let auditDate = $state<string | undefined>(undefined);
  let sprintRange = $state<DatePickerRange>({ start: '2026-08-10', end: '2026-08-16' });
  let quickRange = $state<DatePickerRange | undefined>(undefined);
  let datetimeValue = $state('2026-08-30T14:05');
  let weekdayOnlyDate = $state<string | undefined>(undefined);

  const sprintPresets = [
    { label: 'today', value: todayIso() },
    { label: 'last 7', value: { start: addDays(todayIso(), -6), end: todayIso() } },
    { label: 'last 30', value: { start: addDays(todayIso(), -29), end: todayIso() } },
  ];

  const weekendGuard = (iso: string): boolean => {
    const dow = new Date(iso + 'T00:00:00Z').getUTCDay();
    return dow === 0 || dow === 6;
  };
</script>

<DatePicker label="deploy date" bind:value={deployDate} />
<DatePicker label="review (locale display)" format="locale" bind:value={localeDate} />
<DatePicker label="windowed (min/max)" min="2026-08-04" max="2026-09-16" bind:value={windowedDate} />
<DatePicker label="sprint (range)" mode="range" bind:range={sprintRange} />
<DatePicker label="audit date" error="audit date is required" bind:value={auditDate} />
<DatePicker label="quick picks (presets)" mode="range" presets={sprintPresets} bind:range={quickRange} />
<DatePicker label="date + time (showTime)" showTime format="locale" bind:value={datetimeValue} />
<DatePicker label="weekdays only (isDisabled)" isDisabled={weekendGuard} bind:value={weekdayOnlyDate} />

Open one and keep typing: the panel is a terminal bezel like the Select dropdown, the month label is font-nav uppercase with clamped ←/→ navigation, today reads a --primary border, selected days fill primary, and range interiors wash at color-mix(--primary 14%, transparent). The trigger is the Select trigger's paint — ↑/↓ on it opens the panel, focus restitutes on every close path.

DatePicker usage
const deploy = $state('2026-08-24');
const sprint = $state({ start: '2026-08-10', end: '2026-08-16' });
const at = $state('2026-08-30T14:05'); // canonical datetime

<!-- single: commits ISO "YYYY-MM-DD" — format only changes the display -->
<DatePicker label="deploy date" bind:value={deploy} />
<DatePicker label="review" format="locale" bind:value={deploy} />

<!-- inclusive bounds; outside days render disabled -->
<DatePicker label="windowed" min="2026-08-01" max="2026-08-31" bind:value={deploy} />

<!-- range: first click anchors, second closes (backwards swaps) -->
<DatePicker label="sprint" mode="range" bind:range={sprint} />

<!-- presets: the lane is the component's, the entries are yours —
     plain dates in single mode, {start,end} pairs in range mode -->
<DatePicker
  label="quick picks"
  mode="range"
  presets={[
    { label: 'today', value: todayIso() },
    { label: 'last 7', value: { start: addDays(todayIso(), -6), end: todayIso() } },
  ]}
  bind:range={sprint}
/>

<!-- showTime (v1 single-mode only; range + time is a type error):
     the canonical value becomes "YYYY-MM-DDTHH:mm" local wall-clock —
     the grid mutates the date part, the stepper the time part -->
<DatePicker label="at" showTime format="locale" bind:value={at} />

<!-- isDisabled: your days wear the outside-day law (visible,
     not-allowed, uncommittable); the arrow walk skips them -->
<DatePicker label="weekdays only" isDisabled={(iso) => [0, 6].includes(new Date(iso + 'T00:00:00Z').getUTCDay())} bind:value={deploy} />

api

Props

Props from the DatePicker Props interface; value and range are bindable commit seams. The table renders from the GENERATED meta + curation (the union-alias ceiling: most type cells are honest corrections); variant keeps its own 'auto' — the literal-slot marker, not an axis seat.

Properties

PropertyTypeDefaultDescription
value bindstring—ISO "YYYY-MM-DD" (or canonical "YYYY-MM-DDTHH:mm" with showTime); single mode committed value.
range bindDatePickerRange—{ start?, end? }; range mode committed value.
mode 'single' | 'range''single'Commit mode for the calendar.
showTime booleanfalsev1 single-mode ONLY — mode="range" + showTime is a type error. Canonical "YYYY-MM-DDTHH:mm" local wall-clock value; the TimeStepper row mutates the time part, the grid the date part; each preserves the other.
label string—Field label; renders label[for] above the trigger.
error string—Error text → aria-invalid + describedby + dashed trigger.
placeholder string'Select date...'Trigger text when nothing is committed.
min string—ISO date; earlier days render disabled.
max string—ISO date; later days render disabled.
format 'iso' | 'locale''iso'Display format — the committed value stays canonical regardless.
locale string—BCP 47 locale for the panel vocabulary + the 'locale' display format (Intl.DateTimeFormat); default = the page's <html lang>.
presets DatePickerPreset[]—Quick-pick lane entries ({ label, value: ISO date | { start, end } }); activation rides the exact grid-pick pipeline (commit + close). Malformed values are dropped.
preset Snippet<[DatePickerPreset]>—Snippet escape for per-entry rich content; default renders the label text.
isDisabled (iso: string) => boolean—Consumer day predicate — true days wear the outside-day law (visible, not-allowed, uncommittable); the arrow walk skips them.
id stringautoWired into label[for] / error[id]; auto-generated when omitted.
variant 'solid' | 'acrylic' | 'auto''auto' · Own default, not ambientFloating-surface variant for the panel fill.
class string''Class passthrough.

Universal props

PropertyTypeDefaultDescription
size'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopethe base scale — root font-size; parts size in em
shape'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto''auto' · ambient scopecorner geometry (CSS corner-shape; §14 degrade table)
radius'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopecorner size; auto = the concentric broadcast (§3)
density'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopespacing/leading scale over the kernel channels (§4)
color'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string'auto' · ambient scopethe hue axis of the fixed oklch primary system (§5)
theme'light' | 'dark' | 'system' | 'auto''auto' · ambient scopelight/dark profile; system = the JS-mutable global (§6)
elevation'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number'auto' · ambient scopeofficial M3 levels over the surface ladder (§7)
motion'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number'auto' · ambient scopeintensity across the motion kernels (§8)

axes

The eight axes on date-picker

date-picker resolves all eight axes through its family Defaults and stamps the carriers on the field wrapper (batch A of the explicit-props migration — migration-census.md). One axis repaints: density, through its NAMED rung (the wrapper's data-density scope re-bases the trigger's input-law channels). theme re-themes PARTIALLY — the declaring-selector grep splits the painted voices into raw (flip) and stylex-frozen (stay) halves, both named below. Six stamp-and-supply without touching the paint, recorded with negative-grep receipts instead of invented consumers. The composed consumers — the Calendar and TimeStepper fragments — mount inside the wrapper and inherit every scope.

Reading the table: Property is the axis, Type is the real carrier it stamps on the wrapper here, Default is the lane default — the named steps, number unit, and consumption on this family are in each description.

PropertyTypeDefaultDescription
size 'small' | 'medium' | 'large' | 'auto' | number'auto'SUPPLY-ONLY — stamps --jx-size-effective; no family css reads it (grep receipt: zero --jx-size-effective readers in ui/date-picker/). The trigger rides the input law's fixed channels (--jx-hit/--jx-inset/--jx-leading) and the grid cells are 2rem structural tracks — nothing scales with this stamp. The §1 collision still applies: the axis owns the name, the trigger button never receives a native size attribute. Number unit: px.
shape 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto''auto'SUPPLY-ONLY — stamps --jx-shape-effective and --jx-radius-factor-effective; no family css reads them (grep receipt: zero readers in ui/date-picker/). The panel bezel's corner is border-radius: inherit (the jx-surface-body law) and the day cells are structural squares. Number unit: none.
radius 'small' | 'medium' | 'large' | 'auto' | number'auto'SUPPLY-ONLY — stamps --jx-radius-effective; no family css reads it (grep receipt: zero readers in ui/date-picker/). The panel bezel's corner is the CSS inherit chain (border-radius: inherit on .jx-surface-body), not a carrier computation — the concentric consumers are elsewhere (press-button, card). Number unit: px.
density 'small' | 'medium' | 'large' | 'auto' | number (+ the five legacy spellings)'auto'CONSUMED — through the NAMED lane. The rung stamps data-density on the field wrapper and the scope block re-declares the channels the trigger's input law reads (--jx-hit, --jx-inset, --jx-gap, --jx-leading) INSIDE the scope — the trigger box, its rhythm and everything composed under the wrapper re-base together (the Calendar and TimeStepper fragments are the composed consumers). The NUMBER lane is inert here: it stamps --jx-density-coefficient on the wrapper, but the channels compose their coefficient at :root and the rung scopes — substitution runs at the declaring element, so nothing re-declares (the declaring-element law). auto stamps neither half. Number unit: coefficient.
color 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string'auto'SUPPLY-ONLY — stamps --jx-color-effective; no family css reads it (grep receipt: zero --jx-color-effective readers in ui/date-picker/). The hue that paints rides the theme's primary directly — the selected-day fill, the today border and the range wash read --primary/var(--jx-primary), never the hue carrier. Number unit: hue degrees.
theme 'light' | 'dark' | 'system' | 'auto''auto'PARTIAL re-theme on this family, measured — the drift ledger's declaring-selector grep decides voice by voice. FLIPS under the wrapper's .dark (raw tokens, re-declared per scope): the trigger box (--background/--border/--foreground via the jx-html-input law), its focus ring (--ring), the day/weekday ink (raw --terminal-foreground), the range interior wash (raw --primary in the color-mix), and the bezel fill (--popover). STAYS FROZEN (the stylex --jx-* aliases, substituted at :root): the selected-day fill and today border (--jx-primary pair + --jx-primary-foreground glyph), the placeholder/chevron ink (--jx-muted-foreground), the nav/stepper button ink (--jx-terminal-foreground), the lane seams (--jx-border). The demos below show both halves. light and system stamp nothing — tree inheritance.
elevation 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number'auto'SUPPLY-ONLY — stamps --jx-elevation-effective; no family css reads it. The panel's surface law reads a consumption PAIR — var(--jx-elevation-surface, var(--popover)) and var(--jx-elevation-shadow, none) — but date-picker never stamps that pair (its stampers are toast, terminal-card and press-button), so the lane lands inert on this family's own bezel. Number unit: dp.
motion 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number'auto'SUPPLY-ONLY — stamps --jx-motion-effective; no family css reads it. The panel's real motion is the shared surface-motion kernel: WAAPI animates one --jx-p progress number and the css formulas derive every visible property from it — timings are kernel literals (--motion-150/--motion-100) with the prefers-reduced-motion kill. Number unit: coefficient.
the eight axes on date-picker
<!-- density: the named rung is the axis that repaints this
     family — data-density lands on the field wrapper and the
     trigger's input-law channels (--jx-hit/--jx-inset/--jx-gap)
     re-base inside the scope -->
<DatePicker label="compact (sm rung)" density="small" />
<DatePicker label="touch (lg rung)" density="lg" />

<!-- theme: PARTIAL re-theme, measured — the trigger box (raw
     --background/--border/--foreground) and the day ink flip under
     the wrapper's .dark; the selected-day fill and today border ride
     the frozen --jx-primary pair and keep the light profile -->
<DatePicker label="dark island" theme="dark" value="2026-08-24" />

date-picker · the consumed lanes

density="small" — data-density="sm" on the wrapper; the trigger compacts
density="lg" — data-density="lg"; the touch-size trigger
light — the ambient profile
theme="dark" — the box, ring, day ink and bezel flip; the selected fill and today border stay frozen

Open either theme panel: the bezel, the trigger box and the day ink follow the island's dark profile, while a committed day still fills with the frozen --jx-primary — the split the table names voice by voice.

date-picker-axes.svelte svelte
<!-- density: the named rung is the axis that repaints this
     family — data-density lands on the field wrapper and the
     trigger's input-law channels (--jx-hit/--jx-inset/--jx-gap)
     re-base inside the scope -->
<DatePicker label="compact (sm rung)" density="small" />
<DatePicker label="touch (lg rung)" density="lg" />

<!-- theme: PARTIAL re-theme, measured — the trigger box (raw
     --background/--border/--foreground) and the day ink flip under
     the wrapper's .dark; the selected-day fill and today border ride
     the frozen --jx-primary pair and keep the light profile -->
<DatePicker label="dark island" theme="dark" value="2026-08-24" />
one real query() case
<script lang="ts">
  import DatePicker from '@ui/date-picker.svelte';
  import { query } from '@lib/universal-props-query.svelte';
  import type { DensityLane } from '@lib/defaults.svelte';
</script>

<DatePicker
  label="responsive trigger"
  density={query<{ sm: DensityLane }, DensityLane>({ sm: 'small' }, 'large')}
/>

date-picker · query()

Media keys are min-width: below 40rem the base applies — the large rung, the touch-size trigger; at 40rem and wider the sm case wins and the trigger compacts to the pointer lane. Resize the window across 40rem and watch the box step.

date-picker-query-demo.svelte svelte
<script lang="ts">
  import DatePicker from '@ui/date-picker.svelte';
  import { query } from '@lib/universal-props-query.svelte';
  import type { DensityLane } from '@lib/defaults.svelte';
</script>

<DatePicker
  label="responsive trigger"
  density={query<{ sm: DensityLane }, DensityLane>({ sm: 'small' }, 'large')}
/>
TokenDefaultSource
--jx-hit24 / 28 / 32 / 40 / 48px (2xs → lg)density
--jx-insetrung scale × coefficientdensity
--jx-gaprung scale × coefficientdensity
--jx-leadingrung scale × coefficientdensity
--jx-icon-calendarthe icon vocabulary slotstructural
TokenDefaultSource
--background / --border / --foregroundFLIPS under .darkstructural
--ringFLIPS under .darkstructural
--terminal-foreground (raw)FLIPS under .darkstructural
--primary (raw)FLIPS under .darkstructural
--popoverFLIPS under .darkstructural
--jx-primary / --jx-primary-foregroundFROZEN at :rootstructural
--jx-muted-foregroundFROZEN at :rootstructural
--jx-terminal-foregroundFROZEN at :rootstructural
--jx-borderFROZEN at :rootstructural

a11y

Accessibility

The grid is one focus stop: arrows walk the cursor across month boundaries and skip disabled days, Enter commits, Escape and light dismiss are the platform's.

Keyboard

KeyAction
↑ ↓ ← →On the trigger: open the panel; in the grid: walk the cursor across month boundaries (the view follows) — disabled days (min/max, isDisabled) are skipped
Enter / SpaceCommit the focused day; open the panel from the trigger; preset lane buttons commit like a grid pick
EscapeNative popover dismiss — focus restitutes to the trigger on every close path

ARIA

AttributeValueDescription
aria-invalidtrueSet on the trigger when error is present
aria-describedby{id}-errorReferences the validation message
role: gridone focus stopThe calendar grid is a single tab stop with a roving day cursor
aria-disabledtruePainted on disabled day cells (min/max bounds and isDisabled days)
jixoai-ui ui.jixoai.com the jixoai design language

Theme

Brand hue

0°

Auto cycle · 24h