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
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| Prop | Type | Default | Description |
|---|---|---|---|
| label required | string | — | 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. |
| labelledBy | string | — | 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`. |
| options required | array ({ 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). |
| multiple | boolean | false | 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. |
| value | string | 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. |
| defaultValue | string | string[] | — | Initial selection (or array). |
| selectionFollowsFocus | boolean | true | 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. |
| required | boolean | false | At least one option must be selected to submit when inside a Form. |
| invalid | boolean | false | 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. |
| error | string | — | 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. |
| embedded | boolean | false | 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. |
| initialActiveValue | string | — | 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. |
| activeValue | string | 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. |
| loading | boolean | false | Options are being fetched (async Combobox); the list shows `copy.loading` in place of the empty message and is aria-busy. |
| disabled | boolean | false | 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`. |
| name | string | — | 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). |
| emptyMessage | string | — | Shown when `options` is empty (a filtered Combobox with no matches). Defaults to `copy.empty`. |
| maxVisible | 5 | 8 | 12 | all | 8 | 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. |
Events
2 rows| Event | Type | Description |
|---|---|---|
| onChange | onChange / change | Fired when the selection changes, with the new value (array when `multiple`). |
| onActiveChange | onActiveChange / 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. |
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
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.
This component does not animate, so it has no reduced-motion behaviour to describe.