Components / selection
Listbox
--se-popup-max-h bounds the height so a long list never runs off the viewport and takes its dismiss affordance with it.
Summary
A list the reader picks from, always visible.
| APG pattern | Listbox |
| Built on | native elements |
| Status | stable |
| Since | 0.1.0 |
For a list that opens from a control, use Combobox.
<Listbox id="sort" label="Sort by" defaultValue="recent" options={…} onChange={setSort} />
Anatomy
A data-se="listbox" list of options, with optional groups.
| Part | Element | Required |
|---|---|---|
check | <svg> | yes |
empty | <li> | no |
group-label | <div> | yes |
option | <li> | yes |
Properties
| Property | Values | Default | What it does |
|---|---|---|---|
options | (ListboxOption | ListboxGroup)[] | required | The choices, flat or grouped under a label. |
value | string | string[] | null | none | A string, or an array when multiple. |
defaultValue | string | string[] | null | none | What starts selected. null for nothing, which is a real state here — unlike a radio group. |
onChange | (value: string | string[]) => void | none | Called with the new selection. |
multiple | boolean | none | Allows more than one selection, and enables APG's recommended model: Space toggles, Shift+Arrow and Shift+Space extend from the anchor, and Ctrl/Cmd+A selects or clears everything. |
label | string | none | Accessible name for the list. Required unless aria-labelledby points at a visible heading — an unnamed listbox is announced as "list box" and nothing else, and the component warns about it in development. |
id | string | required | Required — used to build option ids for aria-activedescendant. |
disabled | boolean | none | Removes the list from the tab order. |
controlled | boolean | none | Set by Combobox, which owns focus and drives this list. Not for direct use. |
activeId | string | none | The option aria-activedescendant names. Passing it lets a Combobox drive the highlight while focus stays in its own input. |
markedValue | unknown | none | What the tick marks, when that differs from what is selected. A combobox popup needs the split: aria-selected follows focus there, but the tick must keep showing the committed choice. |
onActiveChange | (id: string) => void | none | Called as the active option moves, whether by arrow key or type-ahead. |
States
| State | Expressed as | |
|---|---|---|
| Focused | :focus-visible | |
| Disabled | aria-disabled | |
| Selected | aria-selected | |
| Highlighted | data-highlighted |
Behavior
Arrow keys, Home and End, and type-ahead move the active option. Enter and Space select.
Focus stays on the list and aria-activedescendant names the active option, rather than moving DOM focus between options. That is what lets Combobox reuse this exact component while focus stays in its text input.
Accessibility
The highlight is drawn by the anatomy, because DOM focus never lands on an option and the browser has nothing to draw.
A group label is role="presentation" on its own element with the group named by aria-label, so the label is not announced as an option.
An empty list says so in data-part="empty" rather than rendering nothing — a list that vanishes gives a screen-reader user no signal at all.
Keyboard
| Key | Result |
|---|---|
Arrow Down / Arrow Up | Move the active option |
Home / End | Jump to the first or last |
Enter | Selects the active option |
| a letter | Type-ahead — moves to the next option starting with it |
Roles, states and properties
| Attribute | Set by |
|---|---|
aria-activedescendant | set at render |
aria-disabled | set at render |
aria-hidden | always true |
aria-label | set at render |
aria-labelledby | set at render |
aria-multiselectable | set at render |
aria-selected | set at render |
role | always option, always listbox, always presentation, always group |
Focus
Focus stays on the list and aria-activedescendant names the active option; DOM focus never moves to an option. That is what lets Combobox reuse this component while focus stays in its text input, and it is why the highlight is the anatomy's job.
Screen reader
Not recorded as verified. Stage 4 of the readiness ladder — driving each component with VoiceOver and NVDA — is unstarted across the package, and no component should claim otherwise until docs/journal/ records the session that did it.
Contrast and target size
npm run check:contrast audits every color pair this component uses across all four skins, light and dark, and fails the build on a shortfall. Two waivers are on the record in docs/audit/DEFERRED.md.
It takes the touch floor under (pointer: coarse) — the visual size is unchanged on a desktop pointer.
Tested
test/apg/partials-listbox.test.jsx
Known gaps
- 6 upstream refs — a listbox behind a button is the menu-button shape
- 2 upstream refs — grouped options, covered by option-role and group-role
- 7 upstream refs — listbox plus move/delete buttons — a composition
- 1 upstream ref — APG's own page or markup, not the pattern
Appearance
| Token | Used for |
|---|---|
--se-accent | color |
--se-border-strong | border |
--se-border-width | border |
--se-control-h-sm | min-block-size |
--se-focus-color | box-shadow, outline |
--se-focus-width | box-shadow, outline, outline-offset |
--se-icon-sm | block-size, inline-size |
--se-popup-max-h | max-block-size |
--se-radius-md | border-radius |
--se-radius-sm | border-radius |
--se-space-1 | padding |
--se-space-2 | gap, padding |
--se-space-3 | padding |
--se-surface | background |
--se-surface-highlight | background |
--se-surface-selected | background |
--se-target-min | min-block-size |
--se-text | color |
--se-text-disabled | color |
--se-text-muted | color |
--se-text-sm | font-size |
--se-weight-medium | font-weight |
Motion
None. A list already on the page has nothing to animate.
Content
Option labels are parallel and short. A grouped list's group labels are categories, not sentences.
Usage
Always visible. If it opens from a control, it is a Combobox.
Not a Menu. A listbox holds a value; a menu fires an action and forgets.
When not to use it. For more than about a dozen options with no filtering — that is a Combobox, where typing is the way through.
Notes
Its surface disagrees with the shared floater's. This one takes se-border-strong and se-radius-md; the floating surface takes the plain border and the large radius. Four surfaces that ought to agree have already drifted, and whether they collapse into one is open in 3.5 — the token names are left unlinked here so the Appearance section above stays the order form and nothing else.
--se-popup-max-h bounds the height so a long list never runs off the viewport and takes its dismiss affordance with it.