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.

Rendered from the package · open on its own

Summary

A list the reader picks from, always visible.

APG patternListbox
Built onnative elements
Statusstable
Since0.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.

PartElementRequired
check<svg>yes
empty<li>no
group-label<div>yes
option<li>yes

Properties

PropertyValuesDefaultWhat it does
options(ListboxOption | ListboxGroup)[]requiredThe choices, flat or grouped under a label.
valuestring | string[] | nullnoneA string, or an array when multiple.
defaultValuestring | string[] | nullnoneWhat starts selected. null for nothing, which is a real state here — unlike a radio group.
onChange(value: string | string[]) => voidnoneCalled with the new selection.
multiplebooleannoneAllows 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.
labelstringnoneAccessible 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.
idstringrequiredRequired — used to build option ids for aria-activedescendant.
disabledbooleannoneRemoves the list from the tab order.
controlledbooleannoneSet by Combobox, which owns focus and drives this list. Not for direct use.
activeIdstringnoneThe option aria-activedescendant names. Passing it lets a Combobox drive the highlight while focus stays in its own input.
markedValueunknownnoneWhat 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) => voidnoneCalled as the active option moves, whether by arrow key or type-ahead.

States

StateExpressed as
Focused:focus-visible
Disabledaria-disabled
Selectedaria-selected
Highlighteddata-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

KeyResult
Arrow Down / Arrow UpMove the active option
Home / EndJump to the first or last
EnterSelects the active option
a letterType-ahead — moves to the next option starting with it

Roles, states and properties

AttributeSet by
aria-activedescendantset at render
aria-disabledset at render
aria-hiddenalways true
aria-labelset at render
aria-labelledbyset at render
aria-multiselectableset at render
aria-selectedset at render
rolealways 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

TokenUsed for
--se-accentcolor
--se-border-strongborder
--se-border-widthborder
--se-control-h-smmin-block-size
--se-focus-colorbox-shadow, outline
--se-focus-widthbox-shadow, outline, outline-offset
--se-icon-smblock-size, inline-size
--se-popup-max-hmax-block-size
--se-radius-mdborder-radius
--se-radius-smborder-radius
--se-space-1padding
--se-space-2gap, padding
--se-space-3padding
--se-surfacebackground
--se-surface-highlightbackground
--se-surface-selectedbackground
--se-target-minmin-block-size
--se-textcolor
--se-text-disabledcolor
--se-text-mutedcolor
--se-text-smfont-size
--se-weight-mediumfont-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.