Listbox

A scrollable list of options with single or multiple selection, driven by arrow keys and typeahead — the selection engine that Select and Combobox open in a popup and that stands alone as a visible picker.

Basic usage

Install the package:

pnpm add @design-schema/react @design-schema/tokens

Import the stylesheet once for the app, then the component:

import '@design-schema/tokens/calm-precise/css';
import '@design-schema/react/index.css';
import { Listbox } from '@design-schema/react';

Examples

Apple
Banana
Cherry
import { Listbox, type ListboxOption } from '@design-schema/react';

const SINGLE_PICKER: ListboxOption[] = [
  { value: 'apple', label: 'Apple' },
  { value: 'banana', label: 'Banana' },
  { value: 'cherry', label: 'Cherry' },
];

<Listbox label="Fruit" options={SINGLE_PICKER} />

Props

18 rows
PropTypeDefaultDescriptionPlatforms
label requiredstring—

Accessible name of the list. When a visible Text label exists, pass its id via `labelledBy` as well; on web `aria-labelledby` then wins. Always pass `label` even with `labelledBy`: it is the `{label}` in `copy.required` and `copy.invalid` on every platform, and on Lit it is always the list's `aria-label`, because an id outside the shadow root cannot be referenced.

web, lit, rn, swiftui
labelledBystring—

Id of a visible element that labels the list. Web only in effect: Lit accepts it for parity (attribute `labelled-by`) but never sets `aria-labelledby` (ids do not cross shadow roots) and names the list with `label` as `aria-label`. The story for it exists on both platforms all the same — on Lit it shows exactly that, a caption beside a list still named by `label`.

web, lit
options requiredarray ({ value: string; label: string; description?: string; icon?: IconName; disabled?: boolean } | { group: string; options: ListboxOption[] })[]—

Flat or grouped options; groups do not nest. Export three types on every platform: `ListboxOption` is the leaf `{ value, label, description?, icon?, disabled? }`, `ListboxGroup` is `{ group, options: ListboxOption[] }`, and `ListboxItem` is `ListboxOption | ListboxGroup`, the element type of this array (Select and Combobox take the same `ListboxItem[]`). This is a breaking rename where a package used `ListboxOption` for the row-or-group union: in-package consumers move to `ListboxItem`/`ListboxGroup`, and where a package actually exported `ListboxGroupOption` that name stays as a deprecated alias of `ListboxOption` — a package that never exported it adds nothing (no platform in this repo had it).

web, lit, rn, swiftui
multiplebooleanfalse

Allow any number of selections. The value becomes an array; each option shows a check indicator; selection toggles rather than moves. This is the same engine Combobox uses for multi-select.

web, lit, rn, swiftui
valuestring | string[]—

Controlled selection: a value, or with `multiple` the exported `ListboxValue` (`string | string[]`). Omit for uncontrolled. The shape that does not match the mode is normalised, not warned about: a single-select list takes an array's first entry, and a multi-select list reads a bare string as a one-entry array.

web, lit, rn, swiftui
defaultValuestring | string[]—

Initial selection (or array).

web, lit, rn, swiftui
selectionFollowsFocusbooleantrue

Single-select only: arrow keys select as they move (the common picker feel). Set false when selection has side effects, so arrows only move and Space selects.

web, lit, rn, swiftui
requiredbooleanfalse

At least one option must be selected to submit when inside a Form.

web, lit, rn, swiftui
invalidbooleanfalse

Marks the list invalid (aria-invalid, and `borderInvalid` when not `embedded`) with `copy.invalid`. The list is invalid while this is true OR `error` is non-empty; clearing `error` never clears an explicitly set `invalid`. A Form-supplied message showing (web and React Native) makes the list invalid the same way, with `aria-invalid` and `borderInvalid`. On Lit the reflected `invalid` attribute is the effective state: setting `error` sets it, and clearing `error` removes it only if `error` was what set it.

web, lit, rn, swiftui
errorstring—

Error message rendered below the list and linked by aria-describedby; implies invalid. The displayed message is `error`, then the Form's message (web and React Native only: Lit's ds-form pushes no message into its fields), then while invalid `copy.required` (required and nothing selected) else `copy.invalid`. The message is not a live region (no `role="alert"`): it is announced through `aria-describedby` when focus reaches the list, which a failed Form submit does. React Native has no `aria-describedby` and the list View is deliberately not `accessible`, so there the displayed message is carried as each row's `accessibilityHint` — the rows are the stops, so that is where a description of the list has to live.

web, lit, rn, swiftui
embeddedbooleanfalse

The list lives inside a popup (Select, Combobox) that owns the border, surface and radius; the list draws none of its own. It keeps its own `listPadding` — that is content spacing, not surface chrome — and an override of `border`, `borderWidth`, `borderInvalid`, `surface` or `radius` is a no-op while it is set, since overrides change values, never presence. An embedded list is not a tab stop (web and Lit render it with `tabindex="-1"`): its host keeps focus on the trigger or input and forwards keys.

web, lit, rn, swiftui
initialActiveValuestring—

The option that is active when the list first receives focus (Select opens with the selected option active). It wins when it names an enabled option; otherwise the first selected, else the first enabled. When it changes while the list has no focus (Combobox updates it as the user types), the active option (native: the pre-highlight) moves to it without firing `onActiveChange`. "No focus" is measured on the list element, not on the page: the test is that the list does not contain the active element (on Lit, that its shadow root's `activeElement` is not the list), so a list driven by a host that holds focus itself — Select, Combobox, Search — always follows the new value.

web, lit, rn, swiftui
activeValuestring | null—

The active option, driven by a host that keeps focus on its own trigger or input and forwards keys (Select, Combobox, Search). Set, it wins over `initialActiveValue` and needs no focus in the list: the active background and `aria-activedescendant` follow it, and `null` means no option is active (how a host clears the highlight, instead of remounting the list). Keys the host forwards move nothing on their own while it is set; they report the option they would make active through `onActiveChange`, and the host passes that value back. Omitted, the list owns the active option as before. The ids a host points `aria-activedescendant` at are the ones the platform notes give (web `${id}-option-${value}`; Lit instance-scoped and index-based, reached by `data-value`). On React Native, where every row is its own stop and there is no highlight to drive, it pre-highlights a row exactly as `initialActiveValue` does.

web, lit, rn, swiftui
loadingbooleanfalse

Options are being fetched (async Combobox); the list shows `copy.loading` in place of the empty message and is aria-busy.

web, lit, rn, swiftui
disabledbooleanfalse

The whole list is inert but readable: it stays focusable (web and Lit keep tabindex=0 and set aria-disabled=true on the list; native sets accessibilityState.disabled on every row), keys, hover, clicks and taps do nothing, and `disabledOpacity` dims the list once (not stacked on individually disabled options; the error message stays at full opacity). Focusing a disabled list sets no active option, fires no `onActiveChange` and shows no active background; the focus ring (native: the focused row's border) is still drawn. A disabled list still resolves `initialActiveValue` and keeps it in state — so enabling the list picks up where it would have been — but draws no active background for it and reports nothing while disabled. When the list is not disabled, each individually disabled option is dimmed by `disabledOpacity`.

web, lit, rn, swiftui
namestring—

Field name for Form collection. The submitted value is a string in single-select and an array of strings with `multiple` (the form `valueType: string[]` covers both); nothing selected submits no key. Without `name` nothing is submitted, though the root still carries `data-ds-field` like every field (Form skips unnamed fields).

web, lit, rn, swiftui
emptyMessagestring—

Shown when `options` is empty (a filtered Combobox with no matches). Defaults to `copy.empty`.

web, lit, rn, swiftui
maxVisible5 | 8 | 12 | all8

Height in rows before the list scrolls; `all` never scrolls. Computed from tokens, never measured: rows × the row height from Behavior, plus 2 × `listPadding`, plus 2 × `borderWidth` when not `embedded`. Rows with a description and group labels are taller or shorter than counted, so slightly fewer or more rows show; that is accepted.

web, lit, rn, swiftui

Events

2 rows
EventTypeDescriptionPlatforms
onChangeonChange / change

Fired when the selection changes, with the new value (array when `multiple`).

web, lit, rn, swiftui
onActiveChangeonActiveChange / active-change

Fired as the focused (active) option changes, with its value — Combobox uses this to keep aria-activedescendant in sync; consumers rarely need it. Fires on keyboard moves, hover, the option made active when the list receives focus (web, Lit) or row focus (native), and with null when the list (native: the active row) loses real focus. Every real row focus reports, including the first focus landing on the row `initialActiveValue` pre-highlighted (whose value has not changed), so a host always learns where focus went; only hover is deduped against the current active value. A host that drives the list without focusing it (Select, Combobox and Search keep focus on their trigger or input and forward keys) gets no null from the list, and clears its own active value when it closes or loses focus. Never on mount, so the native `initialActiveValue` pre-highlight does not fire it.

web, lit, rn, swiftui

Accessibility

Decisions this component leaves to you

Supply an accessible name. This component has no text of its own to fall back on, so without one a screen reader announces its role and nothing else.

Supply the error text when the value is invalid. The component links and announces it; it cannot write it.

Implicit ARIA role: listbox.

Anatomy

listgroupgroupLabeloptionoptionLabeloptionDescriptionoptionIconoptionCheckemptyStateerrorMessage

What generation guarantees

Accessible name — Every instance carries a programmatic name. Where the component has no text of its own to use, the name is a prop the consumer supplies.

Selected state — Selection is exposed programmatically (`aria-selected` / `aria-checked`).

Arrow navigation — Arrow keys move between items; Home and End jump to the ends.

Keyboard operable — Everything the pointer can do here, the keyboard can do — see the keyboard section.

Visible focus — Keyboard focus is always drawn, from the theme’s focus tokens, and is never removed.

Contrast — Text and meaningful non-text contrast meets WCAG AA; the pairs are listed below.

Target size — Interactive targets are at least 24×24 CSS pixels (WCAG 2.2 Target Size, minimum).

Error identification — An invalid value is identified in text and linked to the control — never signalled by colour alone.

Listbox keyboard support

10 rows
KeyAction
ArrowDownMoves to the next enabled option (and selects it when selection follows focus).
ArrowUpMoves to the previous enabled option.
HomeFirst option.
EndLast option.
SpaceSelects the focused option; with `multiple`, toggles it.
EnterSelects the focused option (single) — inside a Select or Combobox, also closes the popup.
Shift+ArrowDown or Shift+ArrowUpMultiple: moves and adds the next/previous option to the selection (add only; an already-selected option stays selected). (multiple)
Control+aMultiple: selects all enabled options; pressed again when every enabled option is selected, deselects the enabled options. Selected disabled options stay selected either way. (multiple)
a-zTypeahead to the next option whose label starts with the typed characters; typing the same character repeatedly cycles through the options starting with it (APG) — a buffer of one letter repeated collapses back to that single letter and searches from the option after the active one, while any other buffer is a prefix searched from the active one. The buffer clears after `typeaheadReset`.
PageDown or PageUpMoves by the `maxVisible` count of enabled options — not of drawn rows, so disabled rows and group labels do not consume the move — clamped to the last/first enabled option; with `maxVisible: all` jumps to the last/first enabled option. Follows `selectionFollowsFocus` like the arrows.

Contrast pairs

7 rows
ForegroundBackgroundLevel
color.foregroundcolor.backgroundAA
color.foregroundcolor.background.subtleAA
color.foreground.mutedcolor.backgroundAA
color.foreground.mutedcolor.background.subtleAA
color.foreground.dangercolor.backgroundAA
color.control.selectedBackgroundcolor.backgroundAA (non-text)
color.control.selectedBackgroundcolor.background.subtleAA (non-text)

This component does not animate, so it has no reduced-motion behaviour to describe.