Skip to examples
Bento / Kitchen sink
Bento / primitives

Pickers

Choices too long to scan, and dates. Each picker takes the same label, hint, and error as a Field and looks the same, and each submits plain values with a form.

Combobox

type to filter · one value

Type a city.

Choose a time zone.

Value: Europe/Istanbul

The query is not the value. Typing only filters; the value changes when an option is chosen, and Escape restores the chosen label.

Source src/lib/components/forms/combobox/doc.ts · src/lib/components/forms/combobox/ChoicePicker.svelte · src/lib/components/forms/combobox/combobox.module.css · src/lib/components/forms/_shared/picker.module.css

src/lib/components/forms/combobox/doc.ts

/**
 * Combobox — one value from a long list, found by typing.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * Every picker takes the same field props as Field — label (REQUIRED), hint,
 * error, required — plus disabled, readOnly, id, and class, and renders them
 * the same way: label above, error then hint below.
 *
 * # Shape
 *
 *     options        { value, label, description?, disabled? }[], REQUIRED
 *     value?         string | null      (controlled)
 *     defaultValue?  string | null      (uncontrolled)
 *     onValueChange? called with the chosen value, or null when cleared
 *     placeholder?, name?, form?, emptyMessage?
 *
 * # Behaviour
 *
 * R1  Typing filters the options by label; the typed text is only a query and
 *     is never submitted or reported as the value.
 * R2  Arrow keys move through matches, Enter chooses, Escape closes and
 *     restores the chosen label. The open button shows every option.
 * R3  A chosen value can be cleared with a Clear button named "Clear
 *     <label>".
 * R4  With no matches it says so in `emptyMessage` (default "No matching
 *     options."), never an empty box.
 * R5  The control is the md control height (forms R1); disabled options are
 *     shown but cannot be chosen.
 * R6  Inside a form it submits the chosen value under `name`.
 *
 * # Combobox or Select?
 *
 * A Select suits a short list the user can scan. When the list is long enough
 * that people would rather type, use a Combobox.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — NOT the oracle.                                             │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * React: react-aria's ComboBox. Svelte: bits-ui's Combobox, with the query
 * kept separate from the value and a hidden input per value for R6.
 */
export {};

src/lib/components/forms/combobox/ChoicePicker.svelte

<script lang="ts">
	import { Combobox as Primitive } from 'bits-ui';
	import { untrack } from 'svelte';
	import { Check, ChevronDown, X } from '$lib/components/utility/icon';
	import { cn } from '$lib/utils/cn';
	import surface from '../../surface.module.css';
	import { labelVariants } from '../label';
	import { formReset } from '../_shared/form-reset';
	import field from '../_shared/picker.module.css';
	import styles from './combobox.module.css';
	import type { ComboboxProps, MultiSelectProps } from './types';

	/* The engine behind Combobox and MultiSelect. The typed query only filters;
	   it never becomes a submitted value. */
	type Props = (ComboboxProps & { mode: 'single' }) | (MultiSelectProps & { mode: 'multiple' });
	let props: Props = $props();

	const generated = $props.id();
	const id = $derived(props.id ?? generated);
	let local = $state.raw<string | readonly string[] | null>(
		untrack(() => props.defaultValue ?? (props.mode === 'single' ? null : []))
	);
	let query = $state('');
	let open = $state(false);
	let input = $state<HTMLInputElement | null>(null);

	const selected = $derived(props.value === undefined ? local : props.value);
	const selectedIds = $derived(
		Array.isArray(selected) ? selected : selected ? [selected as string] : []
	);
	const labelOf = (value: string | null | undefined) =>
		props.options.find((option) => option.value === value)?.label ?? '';
	const matches = $derived(
		props.options.filter((option) =>
			option.label.toLocaleLowerCase().includes(query.toLocaleLowerCase())
		)
	);
	// Error first, then hint, as with Field.
	const describedBy = $derived(
		[props.error ? `${id}-error` : '', props.hint ? `${id}-hint` : ''].filter(Boolean).join(' ') ||
			undefined
	);

	// Native form reset restores the input's DOM default after its event.
	$effect(() => {
		if (!input) return;
		const initial = props.value === undefined ? props.defaultValue : selected;
		input.defaultValue = props.mode === 'single' ? labelOf(initial as string | null) : '';
	});
	// Closing restores the chosen label (or clears the query for multiple).
	$effect(() => {
		if (!open && input)
			input.value = props.mode === 'single' ? labelOf(selected as string | null) : '';
	});
	$effect(() => {
		input?.setCustomValidity(
			props.required && !props.disabled && !props.readOnly && !selectedIds.length
				? 'Choose an option.'
				: ''
		);
	});

	function choose(next: string | string[]) {
		if (props.disabled || props.readOnly) return;
		local = next;
		if (props.mode === 'single')
			props.onValueChange?.(typeof next === 'string' && next ? next : null);
		else props.onValueChange?.(Array.isArray(next) ? next : next ? [next] : []);
		query = '';
	}

	const rootProps = $derived(
		props.mode === 'single'
			? {
					type: 'single' as const,
					value: typeof selected === 'string' ? selected : '',
					onValueChange: (next: string) => choose(next)
				}
			: {
					type: 'multiple' as const,
					value: [...selectedIds],
					onValueChange: (next: string[]) => choose(next)
				}
	);
	const inputValue = $derived(
		props.mode === 'single' && !open ? labelOf(selected as string | null) : query
	);

	function reset() {
		if (props.value !== undefined) return;
		local = props.defaultValue ?? (props.mode === 'single' ? null : []);
		query = '';
		open = false;
		if (input) {
			const label = props.mode === 'single' ? labelOf(local as string | null) : '';
			input.defaultValue = label;
			input.value = label;
		}
	}

	function remove(key: string) {
		choose(selectedIds.filter((value) => value !== key));
	}
</script>

<div class={cn(field.field, props.class)} use:formReset={{ form: props.form, reset }}>
	<div class={field.label}>
		<label for={id} class={labelVariants({ variant: 'field' })}>{props.label}</label>
		{#if props.required}<span aria-hidden="true" class={field.required}>*</span>{/if}
	</div>
	{#if props.readOnly}
		<div class={field.control}>
			<input
				{id}
				class={styles.input}
				readonly
				value={props.mode === 'single' ? inputValue : ''}
				aria-describedby={describedBy}
				aria-invalid={props.error ? 'true' : undefined}
			/>
		</div>
	{:else}
		<Primitive.Root
			{...rootProps}
			bind:open
			{inputValue}
			disabled={props.disabled}
			required={props.required}
			onOpenChange={(value) => {
				if (!value) query = '';
			}}
		>
			<div class={field.control}>
				<Primitive.Input
					bind:ref={input}
					{id}
					class={styles.input}
					placeholder={props.placeholder ?? 'Search options…'}
					aria-describedby={describedBy}
					aria-invalid={props.error ? 'true' : undefined}
					oninput={(event) => {
						query = event.currentTarget.value;
						open = true;
					}}
				/>
				{#if props.mode === 'single' && selectedIds.length && !props.disabled}
					<button
						type="button"
						class={field.iconButton}
						aria-label={`Clear ${props.label}`}
						onclick={() => choose('')}><X aria-hidden="true" /></button
					>
				{/if}
				<Primitive.Trigger class={field.iconButton} aria-label={`Show options for ${props.label}`}>
					<ChevronDown aria-hidden="true" />
				</Primitive.Trigger>
			</div>
			<Primitive.Portal>
				<Primitive.Content
					class={cn(surface.elevated, field.popover, styles.popover)}
					sideOffset={6}
				>
					<Primitive.Viewport class={styles.list}>
						{#each matches as option (option.value)}
							<Primitive.Item
								value={option.value}
								label={option.label}
								disabled={option.disabled}
								class={styles.option}
							>
								<div class={styles.optionText}>
									<span>{option.label}</span>
									{#if option.description}
										<span class={styles.description}>{option.description}</span>
									{/if}
								</div>
								<Check
									aria-hidden="true"
									class={styles.check}
									style={`visibility:${selectedIds.includes(option.value) ? 'visible' : 'hidden'}`}
								/>
							</Primitive.Item>
						{:else}
							<p class={styles.empty}>
								{props.emptyMessage ??
									(props.options.length ? 'No matching options.' : 'No options available.')}
							</p>
						{/each}
					</Primitive.Viewport>
				</Primitive.Content>
			</Primitive.Portal>
		</Primitive.Root>
	{/if}
	{#if props.mode === 'multiple' && selectedIds.length}
		{#if props.disabled || props.readOnly}
			<ul class={styles.tags} aria-label={`Selected ${props.label}`}>
				{#each selectedIds as key (key)}<li class={styles.tag}>{labelOf(key) || key}</li>{/each}
			</ul>
		{:else}
			<div class={styles.tags} role="grid" aria-label={`Selected ${props.label}`}>
				{#each selectedIds as key (key)}
					<div
						class={styles.tag}
						role="row"
						tabindex="0"
						aria-label={labelOf(key) || key}
						onkeydown={(event) => {
							if (event.key === 'Delete' || event.key === 'Backspace') {
								event.preventDefault();
								const row = event.currentTarget;
								const next = row.nextElementSibling ?? row.previousElementSibling;
								remove(key);
								if (next instanceof HTMLElement) next.focus();
								else input?.focus();
							}
							if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {
								event.preventDefault();
								const next =
									event.key === 'ArrowRight'
										? event.currentTarget.nextElementSibling
										: event.currentTarget.previousElementSibling;
								if (next instanceof HTMLElement) next.focus();
							}
						}}
					>
						<div role="gridcell">
							<span>{labelOf(key) || key}</span>
							<button
								type="button"
								class={styles.remove}
								aria-label={`Remove ${labelOf(key) || key}`}
								onclick={() => remove(key)}><X aria-hidden="true" /></button
							>
						</div>
					</div>
				{/each}
			</div>
		{/if}
	{/if}
	{#if props.name}
		{#each selectedIds as key (key)}
			<input
				type="hidden"
				name={props.name}
				value={key}
				form={props.form}
				disabled={props.disabled}
			/>
		{/each}
	{/if}
	{#if props.error}<p id={`${id}-error`} class={field.error}>{props.error}</p>{/if}
	{#if props.hint}<p id={`${id}-hint`} class={field.hint}>{props.hint}</p>{/if}
</div>

src/lib/components/forms/combobox/combobox.module.css

@layer primitive {
	.input {
		min-width: 0;
		height: calc(var(--control-md) - 2px);
		flex: 1;
		padding: 0 var(--space-5);
		border: 0;
		background: transparent;
		color: var(--ink);
		font: inherit;
		/* The control draws the focus ring for the whole group. */
		outline: none;
	}
	.input::placeholder {
		color: var(--ink-3);
	}
	/* react-aria's value wrapper; it must not take part in layout. */
	.clear {
		display: contents;
	}
	.selection:empty {
		display: none;
	}

	.popover {
		width: var(--trigger-width, var(--bits-combobox-anchor-width));
		min-width: min(14rem, calc(100vw - var(--space-6)));
	}
	.list {
		max-height: min(
			18rem,
			var(--available-height, var(--bits-combobox-content-available-height, 18rem))
		);
		overflow: auto;
		padding: var(--space-2);
		outline: none;
	}
	.option {
		display: flex;
		min-height: var(--control-md);
		align-items: center;
		justify-content: space-between;
		gap: var(--space-5);
		padding: var(--space-3) var(--space-5);
		border-radius: var(--radius-1);
		color: var(--ink);
		font-size: var(--text-control, var(--text-13));
		cursor: pointer;
		outline: none;
	}
	/* Highlight follows pointer and keyboard: react-aria says data-focused,
     bits-ui says data-highlighted. */
	.option:is([data-focused], [data-highlighted]) {
		background: var(--surface-hover-2);
	}
	.option[data-selected] {
		font-weight: var(--weight-medium);
	}
	.option[data-disabled] {
		opacity: 0.45;
		cursor: not-allowed;
	}
	.optionText {
		display: grid;
		min-width: 0;
		gap: var(--space-1);
		overflow-wrap: anywhere;
	}
	.description {
		color: var(--ink-3);
		font-size: var(--text-12);
	}
	.check {
		width: 14px;
		height: 14px;
		flex: none;
		color: var(--accent);
	}
	.empty {
		margin: 0;
		padding: var(--space-5);
		color: var(--ink-3);
		font-size: var(--text-12);
	}

	/* MultiSelect's chosen values, below the control. */
	.tags {
		display: flex;
		flex-wrap: wrap;
		gap: var(--space-3);
		margin: 0;
		padding: 0;
		list-style: none;
	}
	.tag {
		display: inline-flex;
		max-width: 100%;
		min-height: 26px;
		align-items: center;
		gap: var(--space-2);
		padding: 0 var(--space-1) 0 var(--space-4);
		border: 1px solid var(--accent-line);
		border-radius: var(--radius-pill);
		background: var(--accent-tint);
		color: var(--ink);
		font-size: var(--text-12);
		overflow-wrap: anywhere;
	}
	.tag > [role='gridcell'] {
		display: contents;
	}
	.tag:is(:focus-visible, [data-focus-visible]) {
		outline: 2px solid var(--accent);
		outline-offset: 2px;
	}
	.remove {
		display: inline-grid;
		width: 22px;
		height: 22px;
		flex: none;
		place-items: center;
		padding: 0;
		border: 0;
		border-radius: var(--radius-pill);
		background: transparent;
		color: var(--ink-2);
		cursor: pointer;
	}
	.remove svg {
		width: 12px;
		height: 12px;
	}
	.remove:hover {
		background: var(--surface-hover-2);
		color: var(--ink);
	}
	.remove:focus-visible {
		outline: 2px solid var(--accent);
		outline-offset: -2px;
	}
}

src/lib/components/forms/_shared/picker.module.css

@layer primitive {
	/* Shared by React (react-aria) and Svelte (bits-ui). Native pseudo-classes
     where possible; where a library state is needed, both libraries'
     attribute names are listed. */
	.field {
		display: grid;
		min-width: 0;
		align-content: start;
		gap: var(--space-3);
	}
	.label {
		display: inline-flex;
		align-items: center;
		gap: var(--space-2);
	}
	.required {
		color: var(--crit);
	}
	.hint,
	.error {
		margin: 0;
		font-size: var(--text-12);
		line-height: var(--leading-snug);
	}
	.hint {
		color: var(--ink-3);
	}
	.error {
		color: var(--crit);
	}

	/* The visible control: an input, optional clear, and an open button. Its
     height is the md control token (forms R1). */
	.control {
		display: flex;
		box-sizing: border-box;
		min-width: 0;
		min-height: var(--control-md);
		align-items: center;
		gap: var(--space-1);
		padding-right: var(--space-2);
		border: 1px solid var(--line-strong);
		border-radius: var(--radius-2);
		background: var(--surface-panel);
		color: var(--ink);
		font-size: var(--text-control, var(--text-13));
		transition: border-color var(--dur-2) var(--ease);
	}
	.control:where(:hover) {
		border-color: var(--line-heavy);
	}
	.control:has(:focus-visible) {
		outline: 2px solid var(--accent);
		outline-offset: 2px;
	}
	.field[data-invalid] .control,
	.control:has([aria-invalid='true']) {
		border-color: var(--crit);
		outline-color: var(--crit);
	}
	.field[data-disabled] .control,
	.control:has(input:disabled) {
		opacity: 0.45;
	}
	.field[data-readonly] .control,
	.control:has(input[readonly]) {
		background: var(--surface-sunk);
	}

	.iconButton {
		display: inline-grid;
		width: calc(var(--control-md) - 8px);
		height: calc(var(--control-md) - 8px);
		flex: none;
		place-items: center;
		padding: 0;
		border: 0;
		border-radius: var(--radius-1);
		background: transparent;
		color: var(--ink-3);
		cursor: pointer;
	}
	.iconButton svg {
		width: 14px;
		height: 14px;
	}
	.iconButton:hover:not(:disabled) {
		background: var(--surface-hover-2);
		color: var(--ink);
	}
	.iconButton:focus-visible {
		outline: 2px solid var(--accent);
		outline-offset: -2px;
	}
	.iconButton:disabled {
		opacity: 0.45;
		cursor: not-allowed;
	}

	.popover {
		z-index: var(--z-palette);
		max-width: calc(100vw - var(--space-6));
		overflow: auto;
		color: var(--ink);
	}
}

MultiSelect

several values · removable tags

Bug
Performance

Choose as many as apply.

  • Documentation

Value: ["bug","perf"]

Tags are keyboard-removable. Focus a tag and press Backspace or Delete; focus moves to its neighbour.

Source src/lib/components/forms/multi-select/doc.ts · src/lib/components/forms/multi-select/MultiSelect.svelte

src/lib/components/forms/multi-select/doc.ts

/**
 * MultiSelect — several values from a list, found by typing.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * Every picker takes the same field props as Field — label (REQUIRED), hint,
 * error, required — plus disabled, readOnly, id, and class, and renders them
 * the same way: label above, error then hint below.
 *
 * # Shape
 *
 * As Combobox, except the value is a list:
 *
 *     value?, defaultValue?   string[]
 *     onValueChange?          called with the new list
 *
 * # Behaviour
 *
 * R1  Choosing an option adds it; choosing it again removes it. The list stays
 *     open while choosing.
 * R2  Chosen values show below the control as tags, in the order chosen. Each
 *     tag has a remove button named "Remove <label>"; Backspace or Delete on a
 *     focused tag removes it and focus moves to a neighbour.
 * R3  When disabled or read-only, the tags are a plain list with no remove
 *     buttons.
 * R4  Inside a form it submits one entry per chosen value under `name`.
 */
export {};

src/lib/components/forms/multi-select/MultiSelect.svelte

<script lang="ts">
	import ChoicePicker from '../combobox/ChoicePicker.svelte';
	import type { MultiSelectProps } from '../combobox/types';

	/* Several values from a searchable list; chosen values show as removable
	   tags below the control. */
	let props: MultiSelectProps = $props();
</script>

<ChoicePicker {...props} mode="multiple" />

DatePicker

YYYY-MM-DD · min · max · unavailable

Start date
05102026

Weekdays only, in October 2026.

Due date
ddmmyyyy

Choose a due date.

Value: 2026-10-05

Dates are strings, not Date objects. A value is "2026-10-05" in and out, so it never shifts by a day in another timezone.

Source src/lib/components/forms/date-picker/doc.ts · src/lib/components/forms/date-picker/DatePicker.svelte · src/lib/components/forms/date-picker/DateCalendar.svelte · src/lib/components/forms/date-picker/date-value.ts · src/lib/components/forms/date-picker/date-picker.module.css

src/lib/components/forms/date-picker/doc.ts

/**
 * DatePicker — one calendar date.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * Every picker takes the same field props as Field — label (REQUIRED), hint,
 * error, required — plus disabled, readOnly, id, and class, and renders them
 * the same way: label above, error then hint below.
 *
 * # Shape
 *
 *     value?, defaultValue?   "YYYY-MM-DD" | null
 *     onValueChange?          called with "YYYY-MM-DD", or null when cleared
 *     minDate?, maxDate?      "YYYY-MM-DD"
 *     isDateUnavailable?      (date: "YYYY-MM-DD") => boolean
 *     locale?                 display locale, default "en-GB"
 *     name?, form?
 *
 * # Behaviour
 *
 * R1  Values are calendar dates as "YYYY-MM-DD" strings, never Date objects,
 *     so a date never shifts by a day across timezones. A malformed string is
 *     an error, not a guess.
 * R2  The date is typed in segments (day, month, year) in the locale's order,
 *     each changeable with the arrow keys, or chosen from a calendar opened by
 *     a button named "Choose <label>".
 * R3  In the calendar, arrow keys move by day, Page Up and Page Down by month,
 *     Home and End to the week's ends. Today is outlined, the chosen day is
 *     filled, and dates outside min/max are disabled.
 * R4  Unavailable dates are struck through and cannot be chosen; a typed
 *     unavailable date is reported as an error.
 * R5  A chosen date can be cleared with a button named "Clear <label>".
 * R6  Inside a form it submits "YYYY-MM-DD" under `name`.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — NOT the oracle.                                             │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * React: react-aria's DatePicker with a Gregorian calendar forced through
 * I18nProvider. Svelte: bits-ui's DatePicker; a visually hidden native date
 * input carries the form value and native validation, and the calendar adds
 * the Page Up/Down and Home/End keys bits-ui does not have.
 */
export {};

src/lib/components/forms/date-picker/DatePicker.svelte

<script module lang="ts">
	import type { DateControlProps } from './date-value';

	export type DatePickerProps = DateControlProps & {
		/** A YYYY-MM-DD string, or null for no date. */
		value?: string | null;
		defaultValue?: string | null;
		onValueChange?: (value: string | null) => void;
		name?: string;
	};
</script>

<script lang="ts">
	import { today, type DateValue } from '@internationalized/date';
	import { DatePicker as Primitive, Portal } from 'bits-ui';
	import { untrack } from 'svelte';
	import { CalendarDays, X } from '$lib/components/utility/icon';
	import { cn } from '$lib/utils/cn';
	import surface from '../../surface.module.css';
	import { labelVariants } from '../label';
	import CalendarFormValue from '../_shared/CalendarFormValue.svelte';
	import { formReset } from '../_shared/form-reset';
	import field from '../_shared/picker.module.css';
	import DateCalendar from './DateCalendar.svelte';
	import { calendarDate, dateConstraints } from './date-value';
	import styles from './date-picker.module.css';

	// The rule cannot see props read as `props.x`; every prop here is used.
	// eslint-disable-next-line svelte/no-unused-props
	let props: DatePickerProps = $props();
	const generated = $props.id();
	const id = $derived(props.id ?? generated);
	let local = $state.raw(untrack(() => props.defaultValue ?? null));
	let open = $state(false);
	let validationError = $state<string | null>(null);
	const error = $derived(props.error ?? validationError);
	const chosen = $derived(props.value === undefined ? local : props.value);
	const calendar = $derived(chosen ? calendarDate(chosen) : undefined);
	const constraints = $derived(dateConstraints(props));
	let placeholder = $state<DateValue>(
		untrack(() => calendar ?? constraints.minValue ?? today('UTC'))
	);
	const describedBy = $derived(
		[error ? `${id}-error` : '', props.hint ? `${id}-hint` : ''].filter(Boolean).join(' ') ||
			undefined
	);

	function clear() {
		if (props.disabled || props.readOnly) return;
		local = null;
		props.onValueChange?.(null);
	}
	function reset() {
		if (props.value !== undefined) return;
		local = props.defaultValue ?? null;
		open = false;
	}
</script>

<div class={cn(field.field, props.class)} use:formReset={{ reset, form: props.form }}>
	<Primitive.Root
		preventDeselect
		bind:placeholder
		value={calendar}
		onValueChange={(next) => {
			const value = next?.toString() ?? null;
			local = value;
			props.onValueChange?.(value);
		}}
		bind:open
		onOpenChange={(value) => {
			if (value) placeholder = calendar ?? placeholder;
		}}
		{...constraints}
		isDateDisabled={constraints.isDateUnavailable}
		locale={props.locale ?? 'en-GB'}
		disabled={props.disabled}
		readonly={props.readOnly}
		required={props.required}
		granularity="day"
		weekdayFormat="short"
		calendarLabel={props.label}
		errorMessageId={error ? `${id}-error` : undefined}
	>
		<div class={field.label}>
			<Primitive.Label class={labelVariants({ variant: 'field' })}>{props.label}</Primitive.Label>
			{#if props.required}<span aria-hidden="true" class={field.required}>*</span>{/if}
		</div>
		<div class={field.control}>
			<div class={styles.inputs}>
				<Primitive.Input
					{id}
					class={styles.input}
					aria-describedby={describedBy}
					aria-invalid={error ? 'true' : undefined}
				>
					{#snippet children({ segments })}
						{#each segments as segment, index (`${segment.part}:${index}`)}
							<Primitive.Segment part={segment.part} class={styles.segment}
								>{segment.value}</Primitive.Segment
							>
						{/each}
					{/snippet}
				</Primitive.Input>
			</div>
			{#if chosen && !props.disabled && !props.readOnly}
				<button
					type="button"
					class={field.iconButton}
					aria-label={`Clear ${props.label}`}
					onclick={clear}><X aria-hidden="true" /></button
				>
			{/if}
			<Primitive.Trigger
				class={field.iconButton}
				aria-label={`Choose ${props.label}`}
				disabled={props.disabled || props.readOnly}
			>
				<CalendarDays aria-hidden="true" />
			</Primitive.Trigger>
		</div>
		<Portal>
			<Primitive.Content
				role="dialog"
				class={cn(surface.elevated, field.popover, styles.popover, styles.dialog)}
				sideOffset={6}
				aria-label={`Choose ${props.label}`}
			>
				<DateCalendar
					locale={props.locale ?? 'en-GB'}
					minDate={props.minDate}
					maxDate={props.maxDate}
					onNavigate={(date) => (placeholder = date)}
				/>
			</Primitive.Content>
		</Portal>
	</Primitive.Root>
	<CalendarFormValue
		value={chosen ?? ''}
		name={props.name}
		form={props.form}
		disabled={props.disabled}
		readOnly={props.readOnly}
		required={props.required}
		min={props.minDate}
		max={props.maxDate}
		error={chosen && props.isDateUnavailable?.(chosen) ? 'Selected date unavailable.' : null}
		focusId={id}
		onValidationChange={(message) => (validationError = message)}
	/>
	{#if error}<p id={`${id}-error`} class={field.error}>{error}</p>{/if}
	{#if props.hint}<p id={`${id}-hint`} class={field.hint}>{props.hint}</p>{/if}
</div>

src/lib/components/forms/date-picker/DateCalendar.svelte

<script lang="ts">
	import { getDayOfWeek, type CalendarDate } from '@internationalized/date';
	import { DatePicker, DateRangePicker } from 'bits-ui';
	import { tick } from 'svelte';
	import { ChevronLeft, ChevronRight } from '$lib/components/utility/icon';
	import { cn } from '$lib/utils/cn';
	import field from '../_shared/picker.module.css';
	import { calendarDate } from './date-value';
	import styles from './date-picker.module.css';

	let {
		range = false,
		locale = 'en-GB',
		minDate,
		maxDate,
		onNavigate
	}: {
		range?: boolean;
		locale?: string;
		minDate?: string;
		maxDate?: string;
		onNavigate: (date: CalendarDate) => void;
	} = $props();

	/* bits-ui moves by day and week; this adds Page Up/Down (month, or year
	   with Shift) and Home/End (the week's ends), the keys react-aria has. */
	async function navigate(event: KeyboardEvent) {
		const element = event.target;
		if (
			!(element instanceof HTMLElement) ||
			!element.matches('[data-bits-day]') ||
			!['PageDown', 'PageUp', 'Home', 'End'].includes(event.key)
		)
			return;
		const value = element.dataset.value;
		if (!value) return;
		event.preventDefault();
		const current = calendarDate(value);
		const direction = event.key === 'PageUp' ? -1 : 1;
		let target =
			event.key === 'Home'
				? current.subtract({ days: getDayOfWeek(current, locale) })
				: event.key === 'End'
					? current.add({ days: 6 - getDayOfWeek(current, locale) })
					: current.add(event.shiftKey ? { years: direction } : { months: direction });
		if (minDate && target.compare(calendarDate(minDate)) < 0) target = calendarDate(minDate);
		if (maxDate && target.compare(calendarDate(maxDate)) > 0) target = calendarDate(maxDate);
		const root = element.closest('[data-calendar-root],[data-range-calendar-root]');
		onNavigate(target);
		await tick();
		const day =
			root?.querySelector<HTMLElement>(
				`[data-value="${target.toString()}"][aria-disabled="false"]`
			) ?? root?.querySelector<HTMLElement>('[data-bits-day][aria-disabled="false"]');
		day?.focus();
	}

	const Primitive = $derived(range ? DateRangePicker : DatePicker);
</script>

<Primitive.Calendar class={cn(styles.calendar, !range && styles.single)} onkeydown={navigate}>
	{#snippet children({ months, weekdays })}
		<Primitive.Header class={styles.header}>
			<Primitive.PrevButton class={field.iconButton} aria-label="Previous month">
				<ChevronLeft aria-hidden="true" />
			</Primitive.PrevButton>
			<Primitive.Heading class={styles.heading} />
			<Primitive.NextButton class={field.iconButton} aria-label="Next month">
				<ChevronRight aria-hidden="true" />
			</Primitive.NextButton>
		</Primitive.Header>
		{#each months as month (month.value.toString())}
			<Primitive.Grid class={styles.grid}>
				<Primitive.GridHead>
					<Primitive.GridRow>
						{#each weekdays as day, i (i)}<Primitive.HeadCell class={styles.weekday}
								>{day}</Primitive.HeadCell
							>{/each}
					</Primitive.GridRow>
				</Primitive.GridHead>
				<Primitive.GridBody>
					{#each month.weeks as week, i (i)}
						<Primitive.GridRow>
							{#each week as date (date.toString())}
								<Primitive.Cell {date} month={month.value}>
									<Primitive.Day class={styles.day} />
								</Primitive.Cell>
							{/each}
						</Primitive.GridRow>
					{/each}
				</Primitive.GridBody>
			</Primitive.Grid>
		{/each}
	{/snippet}
</Primitive.Calendar>
<p class={styles.help}>
	{range ? 'Choose a start and end date.' : 'Choose a date.'} Arrow keys move between days.
</p>

src/lib/components/forms/date-picker/date-value.ts

import { parseDate } from '@internationalized/date';
import type { PickerFieldProps } from '../_shared/picker-field';

export type DateRange = { start: string; end: string };
export type DateControlProps = PickerFieldProps & {
	/** Display locale; values stay Gregorian YYYY-MM-DD strings. */
	locale?: string;
	form?: string;
	minDate?: string;
	maxDate?: string;
	isDateUnavailable?: (date: string) => boolean;
};

/** Never goes through Date, UTC midnight, or toISOString, so a date cannot
 *  shift by a day in another timezone. */
export function calendarDate(value: string) {
	if (!/^\d{4}-\d{2}-\d{2}$/.test(value))
		throw new RangeError(`Expected YYYY-MM-DD, received ${value}`);
	return parseDate(value);
}

export function calendarRange(value: DateRange) {
	// A reversed typed range is editable form state, not a malformed date; the
	// field's validation explains it instead of throwing on rerender.
	return { start: calendarDate(value.start), end: calendarDate(value.end) };
}

/** The calendar blocks gaps; typed endpoints also need the interior checked. */
export function rangeAvailabilityError(
	range: ReturnType<typeof calendarRange>,
	isDateUnavailable?: DateControlProps['isDateUnavailable']
) {
	if (!isDateUnavailable || range.start.compare(range.end) > 0) return null;
	let date = range.start;
	for (;;) {
		if (isDateUnavailable(date.toString())) return 'The range includes unavailable dates.';
		if (date.compare(range.end) >= 0) return null;
		date = date.add({ days: 1 });
	}
}

export function dateConstraints({ minDate, maxDate, isDateUnavailable }: DateControlProps) {
	const minValue = minDate ? calendarDate(minDate) : undefined;
	const maxValue = maxDate ? calendarDate(maxDate) : undefined;
	if (minValue && maxValue && minValue.compare(maxValue) > 0)
		throw new RangeError('Minimum date must not follow maximum date.');
	return {
		minValue,
		maxValue,
		isDateUnavailable: isDateUnavailable
			? (date: { toString(): string }) => isDateUnavailable(date.toString())
			: undefined
	};
}

src/lib/components/forms/date-picker/date-picker.module.css

@layer primitive {
	.inputs {
		display: flex;
		min-width: 0;
		flex: 1;
		flex-wrap: wrap;
		align-items: center;
		gap: 0 var(--space-3);
		padding-left: var(--space-5);
	}
	.input {
		display: flex;
		min-height: calc(var(--control-md) - 2px);
		align-items: center;
		font-variant-numeric: tabular-nums;
		outline: none;
	}
	.segment {
		padding: 0 1px;
		border-radius: var(--radius-1);
		caret-color: transparent;
		outline: none;
	}
	.segment[data-placeholder] {
		color: var(--ink-3);
	}
	.segment:is(:focus, [data-focused]) {
		background: var(--accent-tint);
		color: var(--ink);
	}
	.rangeSeparator {
		color: var(--ink-3);
	}

	.popover {
		width: 18.5rem;
	}
	.dialog {
		padding: var(--space-5);
		outline: none;
	}
	.calendar {
		width: 100%;
	}
	.header {
		display: flex;
		align-items: center;
		justify-content: space-between;
		gap: var(--space-4);
		margin-bottom: var(--space-4);
	}
	.heading {
		flex: 1;
		margin: 0;
		font-size: var(--text-body, var(--text-13));
		font-weight: var(--weight-strong);
		text-align: center;
	}
	.grid {
		width: 100%;
		border-collapse: separate;
		border-spacing: 2px;
		table-layout: fixed;
	}
	.weekday {
		padding-block: var(--space-3);
		color: var(--ink-3);
		font-size: var(--text-11);
		font-weight: var(--weight-strong);
		text-align: center;
	}
	.day {
		display: flex;
		width: 100%;
		aspect-ratio: 1;
		align-items: center;
		justify-content: center;
		border-radius: var(--radius-1);
		font-size: var(--text-12);
		font-variant-numeric: tabular-nums;
		cursor: pointer;
		outline: none;
	}
	.day:hover:not([data-disabled], [data-unavailable]) {
		background: var(--surface-hover-2);
	}
	/* A range's interior is tinted; its ends, and a single chosen day, are
     filled. */
	.day[data-selected] {
		background: var(--accent-tint);
		color: var(--ink);
	}
	.single .day[data-selected],
	.day:is([data-selection-start], [data-selection-end]) {
		background: var(--fill);
		color: var(--fill-ink);
	}
	.day[data-today] {
		box-shadow: inset 0 0 0 1px var(--accent);
		font-weight: var(--weight-strong);
	}
	.day[data-disabled] {
		opacity: 0.35;
		cursor: default;
	}
	.day[data-unavailable] {
		color: var(--ink-3);
		text-decoration: line-through;
		cursor: default;
	}
	.day[data-outside-month] {
		visibility: hidden;
	}
	.day:is(:focus-visible, [data-focus-visible]) {
		outline: 2px solid var(--accent);
		outline-offset: 1px;
	}
	.help {
		margin: var(--space-4) 0 0;
		color: var(--ink-3);
		font-size: var(--text-12);
		line-height: var(--leading-snug);
	}
}

DateRangePicker

start · end · no unavailable days inside

Trip

Weekends cannot be inside the range.

Value: {"start":"2026-10-05","end":"2026-10-09"}

Source src/lib/components/forms/date-range-picker/doc.ts · src/lib/components/forms/date-range-picker/DateRangePicker.svelte

src/lib/components/forms/date-range-picker/doc.ts

/**
 * DateRangePicker — a start date and an end date.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * Every picker takes the same field props as Field — label (REQUIRED), hint,
 * error, required — plus disabled, readOnly, id, and class, and renders them
 * the same way: label above, error then hint below.
 *
 * # Shape
 *
 * As DatePicker, except:
 *
 *     value?, defaultValue?   { start: "YYYY-MM-DD", end: "YYYY-MM-DD" } | null
 *     onValueChange?          called only when both ends are set
 *     startName?, endName?    form names for the two ends
 *
 * # Behaviour
 *
 * R1  Two segmented inputs, start and end, in one control; the calendar
 *     selects the start with the first click and the end with the second.
 * R2  The range's ends are filled and its interior tinted.
 * R3  A range may not contain an unavailable date: while choosing the end,
 *     dates that would span one are disabled, and a typed range that spans
 *     one is reported as "The range includes unavailable dates."
 * R4  The value is reported only when complete; a half-chosen range is state
 *     inside the picker.
 */
export {};

src/lib/components/forms/date-range-picker/DateRangePicker.svelte

<script module lang="ts">
	import type { DateControlProps, DateRange } from '../date-picker/date-value';

	export type DateRangePickerProps = DateControlProps & {
		value?: DateRange | null;
		defaultValue?: DateRange | null;
		onValueChange?: (value: DateRange | null) => void;
		startName?: string;
		endName?: string;
	};
</script>

<script lang="ts">
	import { today, type DateValue } from '@internationalized/date';
	import { DateRangePicker as Primitive, Portal } from 'bits-ui';
	import { untrack } from 'svelte';
	import { CalendarDays, X } from '$lib/components/utility/icon';
	import { cn } from '$lib/utils/cn';
	import surface from '../../surface.module.css';
	import { labelVariants } from '../label';
	import CalendarFormValue from '../_shared/CalendarFormValue.svelte';
	import { formReset } from '../_shared/form-reset';
	import field from '../_shared/picker.module.css';
	import DateCalendar from '../date-picker/DateCalendar.svelte';
	import {
		calendarDate,
		calendarRange,
		dateConstraints,
		rangeAvailabilityError
	} from '../date-picker/date-value';
	import styles from '../date-picker/date-picker.module.css';

	// The rule cannot see props read as `props.x`; every prop here is used.
	// eslint-disable-next-line svelte/no-unused-props
	let props: DateRangePickerProps = $props();
	const generated = $props.id();
	const id = $derived(props.id ?? generated);
	let local = $state.raw(untrack(() => props.defaultValue ?? null));
	let open = $state(false);
	let startError = $state<string | null>(null);
	let endError = $state<string | null>(null);
	const error = $derived(props.error ?? startError ?? endError);
	/* A half-chosen range lives here until both ends are set (doc.ts R4). */
	let candidate = $state.raw<
		| ReturnType<typeof calendarRange>
		| { start: undefined; end: undefined }
		| { start: ReturnType<typeof calendarRange>['start']; end: undefined }
		| null
	>(null);
	const chosen = $derived(props.value === undefined ? local : props.value);
	const calendar = $derived(chosen ? calendarRange(chosen) : { start: undefined, end: undefined });
	const constraints = $derived(dateConstraints(props));
	let placeholder = $state<DateValue>(
		untrack(() => calendar?.start ?? constraints.minValue ?? today('UTC'))
	);
	const describedBy = $derived(
		[error ? `${id}-error` : '', props.hint ? `${id}-hint` : ''].filter(Boolean).join(' ') ||
			undefined
	);

	function clear() {
		if (props.disabled || props.readOnly) return;
		local = null;
		props.onValueChange?.(null);
	}
	function reset() {
		// bits-ui clears its own fields on form reset, which leaves an empty
		// candidate behind; drop it so the restored value shows.
		candidate = null;
		if (props.value !== undefined) return;
		local = props.defaultValue ?? null;
		open = false;
	}
</script>

<div class={cn(field.field, props.class)} use:formReset={{ reset, form: props.form }}>
	<Primitive.Root
		preventDeselect
		bind:placeholder
		value={candidate ?? calendar}
		onValueChange={(next) => {
			candidate = next as typeof candidate;
			if (!next.start || !next.end) return;
			const value = { start: next.start.toString(), end: next.end.toString() };
			local = value;
			props.onValueChange?.(value);
		}}
		onOpenChange={(value) => {
			candidate = null;
			if (value) placeholder = calendar.start ?? placeholder;
		}}
		bind:open
		{...constraints}
		isDateDisabled={(date) => {
			if (constraints.isDateUnavailable?.(date)) return true;
			// While choosing the end, block ends that would span an unavailable day.
			if (!candidate?.start || candidate.end) return false;
			const anchor = calendarDate(candidate.start.toString());
			const endpoint = calendarDate(date.toString());
			return Boolean(
				rangeAvailabilityError(
					anchor.compare(endpoint) <= 0
						? { start: anchor, end: endpoint }
						: { start: endpoint, end: anchor },
					props.isDateUnavailable
				)
			);
		}}
		locale={props.locale ?? 'en-GB'}
		disabled={props.disabled}
		readonly={props.readOnly}
		required={props.required}
		granularity="day"
		weekdayFormat="short"
		calendarLabel={props.label}
		errorMessageId={error ? `${id}-error` : undefined}
		validate={(next) =>
			next.start && next.end
				? (rangeAvailabilityError(
						next as ReturnType<typeof calendarRange>,
						props.isDateUnavailable
					) ?? undefined)
				: undefined}
	>
		<div class={field.label}>
			<Primitive.Label class={labelVariants({ variant: 'field' })}>{props.label}</Primitive.Label>
			{#if props.required}<span aria-hidden="true" class={field.required}>*</span>{/if}
		</div>
		<div class={field.control}>
			<div class={styles.inputs}>
				<Primitive.Input
					type="start"
					{id}
					class={styles.input}
					aria-describedby={describedBy}
					aria-invalid={error ? 'true' : undefined}
				>
					{#snippet children({ segments })}
						{#each segments as segment, index (`${segment.part}:${index}`)}
							<Primitive.Segment part={segment.part} class={styles.segment}
								>{segment.value}</Primitive.Segment
							>
						{/each}
					{/snippet}
				</Primitive.Input>
				<span class={styles.rangeSeparator} aria-hidden="true">–</span>
				<Primitive.Input
					type="end"
					id={`${id}-end`}
					class={styles.input}
					aria-label={`${props.label} end`}
					aria-describedby={describedBy}
				>
					{#snippet children({ segments })}
						{#each segments as segment, index (`${segment.part}:${index}`)}
							<Primitive.Segment part={segment.part} class={styles.segment}
								>{segment.value}</Primitive.Segment
							>
						{/each}
					{/snippet}
				</Primitive.Input>
			</div>
			{#if chosen && !props.disabled && !props.readOnly}
				<button
					type="button"
					class={field.iconButton}
					aria-label={`Clear ${props.label}`}
					onclick={clear}><X aria-hidden="true" /></button
				>
			{/if}
			<Primitive.Trigger
				class={field.iconButton}
				aria-label={`Choose ${props.label}`}
				disabled={props.disabled || props.readOnly}
			>
				<CalendarDays aria-hidden="true" />
			</Primitive.Trigger>
		</div>
		<Portal>
			<Primitive.Content
				role="dialog"
				class={cn(surface.elevated, field.popover, styles.popover, styles.dialog)}
				sideOffset={6}
				aria-label={`Choose ${props.label}`}
			>
				<DateCalendar
					locale={props.locale ?? 'en-GB'}
					range
					minDate={props.minDate}
					maxDate={props.maxDate}
					onNavigate={(date) => (placeholder = date)}
				/>
			</Primitive.Content>
		</Portal>
	</Primitive.Root>
	<CalendarFormValue
		value={candidate ? (candidate.start?.toString() ?? '') : (chosen?.start ?? '')}
		name={props.startName}
		form={props.form}
		disabled={props.disabled}
		readOnly={props.readOnly}
		required={props.required}
		min={props.minDate}
		max={props.maxDate}
		error={chosen ? rangeAvailabilityError(calendarRange(chosen), props.isDateUnavailable) : null}
		focusId={id}
		onValidationChange={(message) => (startError = message)}
	/>
	<CalendarFormValue
		value={candidate ? (candidate.end?.toString() ?? '') : (chosen?.end ?? '')}
		name={props.endName}
		form={props.form}
		disabled={props.disabled}
		readOnly={props.readOnly}
		required={props.required}
		min={chosen?.start ?? props.minDate}
		max={props.maxDate}
		focusId={`${id}-end`}
		onValidationChange={(message) => (endError = message)}
		error={candidate?.start && !candidate.end ? 'Complete the date range.' : null}
	/>
	{#if error}<p id={`${id}-error`} class={field.error}>{error}</p>{/if}
	{#if props.hint}<p id={`${id}-hint`} class={field.hint}>{props.hint}</p>{/if}
</div>

In a form

what actually gets submitted

Bug
Documentation
Start
05102026
Window
—

Plain values only. The Combobox submits its value, not what was typed; the MultiSelect submits one entry per tag; dates submit YYYY-MM-DD. Reset restores the defaults.