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.

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

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.

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

Start date
Weekdays only, in October 2026.
Due date
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.

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

Trip
Weekends cannot be inside the range.

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

Bug
Documentation
Start
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.