Listbox
An always-visible list of selectable options using role="listbox" and role="option", supporting single or multiple selection with roving tabindex or aria-activedescendant.
Selection
The criteria an agent checks before retrieving this component.
Use when
- Use when the choices are permanently visible with no popup or expansion (e.g., an inline "Playback quality" list, a settings choice shown in place).
- Use when the user selects one option or multiple options from plain-text labels (e.g., "Select genres to follow", the source column of a transfer list).
Try a different component when
- Do not use when a collapsed popup value picker is expected (use
select.native, orselect.basicfor a custom-styled one). - Do not use when the user types to filter the options (use
combobox.autocomplete). - Do not use when the options must contain interactive elements such as links or buttons (use
grid.basic). - Do not use when the user chooses among many independent on and off settings (use
checkbox.group). - Do not use when the control performs actions rather than holding a selection (use
menu.basic).
Must Haves
Non-negotiable structure. Every generated instance must satisfy these rules.
Roles & structure
- The container is an element with
role="listbox"and an accessible name viaaria-labeloraria-labelledby. - Each option is an element with
role="option"andaria-selected="true|false". - Options contain no interactive child elements (no links, buttons, inputs, or nested controls).
State & properties
- For multiple selection, the listbox has
aria-multiselectable="true".
Keyboard
- Arrow Down and Arrow Up move the active option to the next and previous option.
- Home and End move the active option to the first and last option.
- A printable character performs type-ahead, moving the active option to the next option whose label begins with the typed characters.
- For single-select, selection is operable from the keyboard alone.
- For multi-select, the selection is operable from the keyboard alone.
- Space toggles
aria-selectedon the active option. - Shift+Arrow Up and Shift+Arrow Down extend the selection to the adjacent option.
- Ctrl+A (Cmd+A on macOS) selects all options.
- No modifier-only click is required to select an option.
- Space toggles
Focus
- Manage the active option with either roving tabindex or
aria-activedescendant, and apply one approach consistently.- Roving tabindex: the focused option has
tabindex="0"and all other options havetabindex="-1". aria-activedescendant: therole="listbox"element hastabindex="0"and itsaria-activedescendantreferences the active option's ID.
- Roving tabindex: the focused option has
- Scripting keeps the active option scrolled into view.
- Ensure a visible focus state (e.g., a 2px solid outline offset by 1-2px) around the active option.
Donts
Avoid these accessibility and UX barriers.
- Do not place interactive elements (links, buttons, checkboxes, inputs) inside
role="option"elements. - Do not require Ctrl or Cmd plus click as the only way to select multiple options.
- Do not use
role="menu"orrole="menuitem"for a selection list. - Do not leave
aria-selectedout of sync with the visible selection state.
Customizable
Alternatives and options that give the AI agent some room to move.
- Roving tabindex and
aria-activedescendantare equal alternatives. Choose one and apply it consistently. - For single-select, selection may follow focus (each Arrow move updates
aria-selected) or require an explicit Enter or Space to commit. Selection-follows-focus suits short lists where each change is inexpensive; explicit selection suits lists where moving through options should not trigger side effects. - The multi-select keyboard model above (Space to toggle, Shift+Arrow to extend, Ctrl+A to select all) may be extended with Shift+Home, Shift+End, and Ctrl+Space, provided Space toggle and Arrow navigation remain intact.
Golden Pattern
The tested reference implementation. Agents start from this shape and adapt to the developer’s codebase and context.
"use client";
function Listbox({ label, options, multiple = false }) {
const optionRefs = useRef([]);
const typeahead = useRef({ query: "", timer: null });
const [activeIndex, setActiveIndex] = useState(0);
const [selected, setSelected] = useState(() => (multiple ? new Set() : null));
const [hasFocus, setHasFocus] = useState(false);
const labelId = useId();
function isSelected(value) {
return multiple ? selected.has(value) : selected === value;
}
function toggleAt(index) {
const { value } = options[index];
if (multiple) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(value)) next.delete(value);
else next.add(value);
return next;
});
} else {
setSelected(value);
}
}
function focusOption(index) {
setActiveIndex(index);
// Roving tabindex: move DOM focus to the newly active option.
optionRefs.current[index]?.focus();
}
// Type-ahead: accumulate typed characters briefly, then jump to the
// first option whose label matches, so a keyboard user can reach a
// distant option without arrowing through every one.
function typeAhead(char) {
const state = typeahead.current;
clearTimeout(state.timer);
state.query += char.toLowerCase();
state.timer = setTimeout(() => {
state.query = "";
}, 500);
const match = options.findIndex((opt) =>
opt.label.toLowerCase().startsWith(state.query)
);
if (match >= 0) focusOption(match);
}
function onKeyDown(e) {
const last = options.length - 1;
let next = activeIndex;
switch (e.key) {
case "ArrowDown":
next = Math.min(last, activeIndex + 1);
break;
case "ArrowUp":
next = Math.max(0, activeIndex - 1);
break;
case "Home":
next = 0;
break;
case "End":
next = last;
break;
case " ":
// Space toggles in multi-select and commits in single-select;
// preventDefault stops the page from scrolling.
e.preventDefault();
toggleAt(activeIndex);
return;
case "Enter":
// Single-select: Enter commits the active option.
if (!multiple) toggleAt(activeIndex);
return;
default:
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
typeAhead(e.key);
}
return;
}
e.preventDefault();
focusOption(next);
// Single-select: selection follows focus.
if (!multiple) toggleAt(next);
}
return (
<div>
<span id={labelId}>{label}</span>
<ul
role="listbox"
aria-labelledby={labelId}
aria-multiselectable={multiple ? "true" : undefined}
onKeyDown={onKeyDown}
onFocus={() => setHasFocus(true)}
onBlur={() => setHasFocus(false)}
style={{
listStyle: "none",
margin: 0,
padding: 0,
border: "1px solid",
maxHeight: 200,
overflow: "auto",
}}
>
{options.map((opt, idx) => {
const isActive = idx === activeIndex;
return (
<li
key={opt.value}
ref={(el) => (optionRefs.current[idx] = el)}
role="option"
aria-selected={isSelected(opt.value) ? "true" : "false"}
// Roving tabindex: only the active option is in the tab order.
tabIndex={isActive ? 0 : -1}
onClick={() => {
focusOption(idx);
toggleAt(idx);
}}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "6px 8px",
cursor: "default",
// Focus ring keys to real DOM focus, not just active index,
// so it disappears when the listbox is not focused.
outline: isActive && hasFocus ? "2px solid" : "none",
outlineOffset: 1,
}}
>
{/* Selection is conveyed by aria-selected; the mark is decorative. */}
<span aria-hidden="true" style={{ width: "1em" }}>
{isSelected(opt.value) ? "✓" : ""}
</span>
{opt.label}
</li>
);
})}
</ul>
</div>
);
}
export function ListboxDemo() {
return (
<div>
<Listbox label="Playback quality" options={QUALITY} />
<Listbox label="Genres to follow" options={GENRES} multiple />
</div>
);
}
const QUALITY = [
{ value: "auto", label: "Auto" },
{ value: "1080p", label: "1080p" },
{ value: "720p", label: "720p" },
{ value: "480p", label: "480p" },
];
const GENRES = [
{ value: "drama", label: "Drama" },
{ value: "comedy", label: "Comedy" },
{ value: "thriller", label: "Thriller" },
{ value: "documentary", label: "Documentary" },
{ value: "sci-fi", label: "Sci-Fi" },
];
Acceptance Checks
The component’s test spec — an optional body of checks for verification.
Keyboard
- Arrow Down and Arrow Up move the active option, and the active option shows a visible focus indicator.
- Home moves the active option to the first option; End moves it to the last option.
- In a multi-select listbox, Space toggles the selected state of the active option without moving focus.
- In a multi-select listbox, Shift+Arrow Up and Shift+Arrow Down extend the selection to the adjacent option.
- In a multi-select listbox, Ctrl+A (Cmd+A on macOS) selects all options.
- Typing a printable character moves the active option to the next option whose label begins with the typed characters.
- No option requires Ctrl or Cmd plus click to be selected.
Screen Reader
- The container is announced as a listbox, and as multi-selectable when
aria-multiselectable="true"is set. - Each option is announced as an option together with its selected state (from
aria-selected). - Moving the active option announces the newly active option and its selected state.