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
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.
Sourcecomponents/forms/combobox/doc.ts · components/forms/combobox/combobox.tsx · components/forms/combobox/combobox.module.css · components/forms/_shared/picker.module.css
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 {};components/forms/combobox/combobox.tsx
"use client";
import {
Button,
ComboBox as Primitive,
ComboBoxValue,
Group,
Input,
ListBox,
ListBoxItem,
Popover,
Tag,
TagGroup,
TagList,
Text,
} from "react-aria-components";
import { Check, ChevronDown, X } from "@/components/utility/icon";
import { cn } from "@/lib/utils/cn";
import surface from "../../surface.module.css";
import { PickerLabel, PickerMessages } from "../_shared/picker-labels";
import field from "../_shared/picker.module.css";
import styles from "./combobox.module.css";
import type { ChoiceOption, ComboboxProps, MultiSelectProps } from "./types";
export function Combobox(props: ComboboxProps) {
return <ChoicePicker {...props} mode="single" />;
}
/** The engine behind Combobox and MultiSelect. The typed query only filters;
* it never becomes a submitted value. */
export function ChoicePicker(
props:
| (ComboboxProps & { mode: "single" })
| (MultiSelectProps & { mode: "multiple" }),
) {
const {
options,
label,
hint,
error,
disabled,
readOnly,
required,
id,
name,
form,
placeholder = "Search options…",
className,
emptyMessage,
mode,
} = props;
const multiple = mode === "multiple";
const value =
props.mode === "multiple" ? props.value && [...props.value] : props.value;
const defaultValue =
props.mode === "multiple"
? props.defaultValue && [...props.defaultValue]
: props.defaultValue;
return (
<Primitive<ChoiceOption, "single" | "multiple">
id={id}
name={name}
form={form}
selectionMode={mode}
defaultItems={options}
disabledKeys={options
.filter((option) => option.disabled)
.map((option) => option.value)}
value={value}
defaultValue={defaultValue}
onChange={(next) => {
if (props.mode === "multiple")
props.onValueChange?.(Array.isArray(next) ? next.map(String) : []);
else props.onValueChange?.(next === null ? null : String(next));
}}
isDisabled={disabled}
isReadOnly={readOnly}
isRequired={required}
isInvalid={Boolean(error) || undefined}
allowsEmptyCollection
className={cn(field.field, className)}
>
<PickerLabel label={label} required={required} />
<Group className={field.control}>
<Input className={styles.input} placeholder={placeholder} />
{!multiple ? (
<ComboBoxValue<ChoiceOption> className={styles.clear}>
{({ state }) =>
state.value !== null && !disabled && !readOnly ? (
<Button
slot={null}
className={field.iconButton}
aria-label={`Clear ${label}`}
onPress={() => {
state.setValue(null);
state.setInputValue("");
}}
>
<X aria-hidden="true" />
</Button>
) : (
// An empty fragment, not null: react-aria renders the value
// text as a fallback when this returns null.
<></>
)
}
</ComboBoxValue>
) : null}
<Button
className={field.iconButton}
aria-label={`Show options for ${label}`}
>
<ChevronDown aria-hidden="true" />
</Button>
</Group>
{multiple ? (
<ComboBoxValue<ChoiceOption> className={styles.selection}>
{({ selectedItems, state }) => {
const selected = selectedItems.filter(
(item): item is ChoiceOption => item !== null,
);
if (!selected.length) return null;
if (disabled || readOnly)
return (
<ul className={styles.tags} aria-label={`Selected ${label}`}>
{selected.map((item) => (
<li key={item.value} className={styles.tag}>
{item.label}
</li>
))}
</ul>
);
return (
<TagGroup
aria-label={`Selected ${label}`}
onRemove={(keys) => {
if (Array.isArray(state.value))
state.setValue(state.value.filter((key) => !keys.has(key)));
}}
>
<TagList items={selected} className={styles.tags}>
{(item) => (
<Tag
id={item.value}
textValue={item.label}
className={styles.tag}
>
<span>{item.label}</span>
<Button
slot="remove"
aria-label={`Remove ${item.label}`}
className={styles.remove}
>
<X aria-hidden="true" />
</Button>
</Tag>
)}
</TagList>
</TagGroup>
);
}}
</ComboBoxValue>
) : null}
<PickerMessages hint={hint} error={error} />
<Popover
placement="bottom start"
offset={6}
className={cn(surface.elevated, field.popover, styles.popover)}
>
<ListBox<ChoiceOption>
className={styles.list}
renderEmptyState={() => (
<p className={styles.empty}>
{emptyMessage ??
(options.length
? "No matching options."
: "No options available.")}
</p>
)}
>
{(option) => (
<ListBoxItem
id={option.value}
textValue={option.label}
className={styles.option}
>
{({ isSelected }) => (
<>
<div className={styles.optionText}>
<Text slot="label">{option.label}</Text>
{option.description ? (
<Text slot="description" className={styles.description}>
{option.description}
</Text>
) : null}
</div>
<Check
aria-hidden="true"
className={styles.check}
style={{ visibility: isSelected ? "visible" : "hidden" }}
/>
</>
)}
</ListBoxItem>
)}
</ListBox>
</Popover>
</Primitive>
);
}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;
}
}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
Value: ["bug","perf"]
Tags are keyboard-removable. Focus a tag and press Backspace or Delete; focus moves to its neighbour.
Sourcecomponents/forms/multi-select/doc.ts · components/forms/multi-select/multi-select.tsx
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 {};components/forms/multi-select/multi-select.tsx
"use client";
import { ChoicePicker } from "../combobox/combobox";
import type { MultiSelectProps } from "../combobox/types";
export type { MultiSelectProps } from "../combobox/types";
/** Several values from a searchable list; chosen values show as removable
* tags below the control. */
export function MultiSelect(props: MultiSelectProps) {
return <ChoicePicker {...props} mode="multiple" />;
}DatePicker
YYYY-MM-DD · min · max · unavailable
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.
Sourcecomponents/forms/date-picker/doc.ts · components/forms/date-picker/date-picker.tsx · components/forms/date-picker/date-value.ts · components/forms/date-picker/date-picker.module.css
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 {};components/forms/date-picker/date-picker.tsx
"use client";
import {
Button,
Calendar,
CalendarCell,
CalendarGrid,
CalendarGridBody,
CalendarGridHeader,
CalendarHeaderCell,
DateInput,
DatePicker as Primitive,
DateSegment,
Dialog,
Group,
Heading,
I18nProvider,
Popover,
RangeCalendar,
} from "react-aria-components";
import {
CalendarDays,
ChevronLeft,
ChevronRight,
X,
} from "@/components/utility/icon";
import { cn } from "@/lib/utils/cn";
import surface from "../../surface.module.css";
import { PickerLabel, PickerMessages } from "../_shared/picker-labels";
import field from "../_shared/picker.module.css";
import styles from "./date-picker.module.css";
import {
calendarDate,
dateConstraints,
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;
};
export function DatePicker(props: DatePickerProps) {
const {
label,
hint,
error,
required,
disabled,
readOnly,
id,
className,
locale = "en-GB",
value,
defaultValue,
onValueChange,
name,
form,
} = props;
return (
<I18nProvider
locale={new Intl.Locale(locale, { calendar: "gregory" }).toString()}
>
<Primitive
id={id}
name={name}
form={form}
className={cn(field.field, className)}
value={
value === undefined
? undefined
: value === null
? null
: calendarDate(value)
}
defaultValue={defaultValue ? calendarDate(defaultValue) : undefined}
onChange={
onValueChange
? (next) => onValueChange(next?.toString() ?? null)
: undefined
}
{...dateConstraints(props)}
isDisabled={disabled}
isReadOnly={readOnly}
isRequired={required}
isInvalid={Boolean(error) || undefined}
granularity="day"
shouldForceLeadingZeros
>
{({ state }) => (
<>
<PickerLabel label={label} required={required} />
<DateControl
label={label}
clear={() => state.setValue(null)}
canClear={Boolean(state.value) && !disabled && !readOnly}
/>
<PickerMessages hint={hint} error={error} />
<DatePopover label={label} />
</>
)}
</Primitive>
</I18nProvider>
);
}
/** The segmented input(s), clear, and open button; shared with the range. */
export function DateControl({
label,
range = false,
clear,
canClear,
}: {
label: string;
range?: boolean;
clear: () => void;
canClear: boolean;
}) {
const segments = (slot?: "start" | "end") => (
<DateInput slot={slot} className={styles.input}>
{(segment) => (
<DateSegment segment={segment} className={styles.segment} />
)}
</DateInput>
);
return (
<Group className={field.control}>
<div className={styles.inputs}>
{segments(range ? "start" : undefined)}
{range ? (
<>
<span className={styles.rangeSeparator} aria-hidden="true">
–
</span>
{segments("end")}
</>
) : null}
</div>
{canClear ? (
<Button
slot={null}
className={field.iconButton}
aria-label={`Clear ${label}`}
onPress={clear}
>
<X aria-hidden="true" />
</Button>
) : null}
<Button className={field.iconButton} aria-label={`Choose ${label}`}>
<CalendarDays aria-hidden="true" />
</Button>
</Group>
);
}
export function DatePopover({
label,
range = false,
}: {
label: string;
range?: boolean;
}) {
const body = (
<>
<div className={styles.header}>
<Button
slot="previous"
className={field.iconButton}
aria-label="Previous month"
>
<ChevronLeft aria-hidden="true" />
</Button>
<Heading className={styles.heading} />
<Button
slot="next"
className={field.iconButton}
aria-label="Next month"
>
<ChevronRight aria-hidden="true" />
</Button>
</div>
<CalendarGrid className={styles.grid} weekdayStyle="short">
<CalendarGridHeader>
{(day) => (
<CalendarHeaderCell className={styles.weekday}>
{day}
</CalendarHeaderCell>
)}
</CalendarGridHeader>
<CalendarGridBody>
{(date) => <CalendarCell date={date} className={styles.day} />}
</CalendarGridBody>
</CalendarGrid>
</>
);
return (
<Popover
placement="bottom start"
offset={6}
className={cn(surface.elevated, field.popover, styles.popover)}
>
<Dialog aria-label={`Choose ${label}`} className={styles.dialog}>
{range ? (
<RangeCalendar className={styles.calendar}>{body}</RangeCalendar>
) : (
<Calendar className={cn(styles.calendar, styles.single)}>
{body}
</Calendar>
)}
<p className={styles.help}>
{range ? "Choose a start and end date." : "Choose a date."} Arrow keys
move between days.
</p>
</Dialog>
</Popover>
);
}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,
};
}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
Value: {"start":"2026-10-05","end":"2026-10-09"}
Sourcecomponents/forms/date-range-picker/doc.ts · components/forms/date-range-picker/date-range-picker.tsx
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 {};components/forms/date-range-picker/date-range-picker.tsx
"use client";
import {
DateRangePicker as Primitive,
I18nProvider,
} from "react-aria-components";
import { cn } from "@/lib/utils/cn";
import { PickerLabel, PickerMessages } from "../_shared/picker-labels";
import field from "../_shared/picker.module.css";
import { DateControl, DatePopover } from "../date-picker/date-picker";
import {
calendarRange,
dateConstraints,
rangeAvailabilityError,
type DateControlProps,
type DateRange,
} from "../date-picker/date-value";
export type { 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;
};
export function DateRangePicker(props: DateRangePickerProps) {
const {
label,
hint,
error,
required,
disabled,
readOnly,
id,
className,
locale = "en-GB",
value,
defaultValue,
onValueChange,
startName,
endName,
} = props;
return (
<I18nProvider
locale={new Intl.Locale(locale, { calendar: "gregory" }).toString()}
>
<Primitive
id={id}
startName={startName}
endName={endName}
className={cn(field.field, className)}
value={
value === undefined
? undefined
: value === null
? null
: calendarRange(value)
}
defaultValue={defaultValue ? calendarRange(defaultValue) : undefined}
onChange={
onValueChange
? (next) =>
onValueChange(
next
? { start: next.start.toString(), end: next.end.toString() }
: null,
)
: undefined
}
{...dateConstraints(props)}
allowsNonContiguousRanges={false}
validate={(next) =>
next ? rangeAvailabilityError(next, props.isDateUnavailable) : null
}
isDisabled={disabled}
isReadOnly={readOnly}
isRequired={required}
isInvalid={Boolean(error) || undefined}
granularity="day"
shouldForceLeadingZeros
>
{({ state }) => (
<>
<PickerLabel label={label} required={required} />
<DateControl
label={label}
range
clear={() => state.setValue(null)}
canClear={
Boolean(state.value?.start || state.value?.end) &&
!disabled &&
!readOnly
}
/>
<PickerMessages hint={hint} error={error} />
<DatePopover label={label} range />
</>
)}
</Primitive>
</I18nProvider>
);
}In a form
what actually gets submitted
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.