Accessible Listbox Guide
A listbox lets a user pick from a set of choices — one option, or several. This guide covers the role="listbox" / option / group structure, single-select where selection follows focus, multi-select with aria-multiselectable and Space to toggle, aria-selected, the roving-tabindex vs aria-activedescendant focus choice, grouped and disabled options, type-ahead, and React — with copy-ready code mapped to WCAG 2.2, and the one question to ask before you build one at all.
The Widget for Choosing From a Set
A listbox is the control you reach for when the user needs to choose from a set of options that is too long, too dynamic, or too dense for a row of radio buttons or checkboxes to hold comfortably — a scrolling list of tags, a picker of file types, the popup a combobox opens. On screen it is simply a list. What makes it a listbox to assistive technology is a specific bundle of semantics and a specific keyboard model: one Tab stop, arrow keys that move a single roving focus through the options, and a selected state carried by aria-selected.
The listbox has an ARIA pattern of its own — the WAI-ARIA Authoring Practices Listbox pattern — built from role="listbox" and role="option", with aria-selected for what is chosen, aria-multiselectable when more than one option can be selected, role="group" to cluster related options, and aria-disabled for options that cannot be picked. Like a tree or a menu, there is no single native element that gives you a fully custom listbox — a native <select> is close but styling and multi-select behaviour are where it runs out — so the roles, the roving focus, and the keyboard model are yours to wire up.
Before anything else: most “choose one” and “choose several” problems are not listboxes. For a small, fully visible set of options, native radio buttons or checkboxes are more familiar, need no JavaScript, and cannot drift out of sync. Reserve role="listbox" for long, scrolling, or single-Tab-stop lists. Section 1 is the decision.
This guide walks that decision first, then the anatomy, the HTML structure, the full keyboard model, the selection-follows-focus behaviour that defines a single-select listbox, the separate focus-and-selection model of a multi-select one, the roving-tabindex vs aria-activedescendant focus choice, grouped and disabled options, a React approach, and how to test the result.
The WCAG 2.2 Criteria a Listbox Must Satisfy
| Criterion | Level | What the listbox must do |
|---|---|---|
| 1.3.1 Info & Relationships | A | The list, each option, its position in the set, its selected state, and any grouping are exposed programmatically — not implied by visual layout alone. |
| 2.1.1 Keyboard | A | Every action — move between options, jump to first and last, type-ahead, select and, in multi-select, toggle and extend — works from the keyboard alone. |
| 2.4.3 Focus Order | A | Roving tabindex (or aria-activedescendant) keeps the listbox a single Tab stop, and focus moves through the options in a predictable order. |
| 1.4.1 Use of Color | A | Which option is selected is shown by more than colour — aria-selected plus a checkmark, checkbox, or outline, not a background tint alone. |
| 2.4.7 Focus Visible | AA | The focused option shows a clearly visible indicator that is distinct from the selected-option styling. |
| 1.4.11 Non-text Contrast | AA | The focus indicator, the selection indicator, and any checkmark or checkbox glyph each reach at least 3:1 against what they sit on. |
| 2.5.8 Target Size (Minimum) | AA | Each option is a pointer target of at least 24 by 24 CSS pixels, so it can be tapped or clicked without precision. |
| 4.1.2 Name, Role, Value | A | The listbox exposes its role and name; each option exposes the option role, its label, and its selected and disabled state. |
The criterion a listbox fails most often is 4.1.2 Name, Role, Value, because the selected state lives in a CSS class instead of aria-selected, so a screen reader user can see nothing is chosen — closely followed by 2.1.1 Keyboard when the arrow-key model was never wired up at all.
1. Listbox, Radios, Checkboxes, or a Native Select?
A listbox asks you to own the roving focus, the selection model, and the whole keyboard contract. Reach for it only when a simpler, native control genuinely will not fit. For many things that look like they need a listbox, radio buttons, checkboxes, or a plain <select> are more robust and far less code. Match the control to the job before you build.
Radio buttons or checkboxes
A small, fully visible set of choices — pick one (radios) or several (checkboxes). Native HTML in a <fieldset> with a <legend>. No ARIA, no JavaScript, instantly familiar. See the forms guide.
Native select
A choose-one dropdown that does not need custom styling or rich option markup. A native <select> gives you the popup, the keyboard, and a real listbox under the hood — for free, on every device.
Listbox
A long, scrolling, or single-Tab-stop list of choices that needs custom styling or must live in one Tab stop — or the popup a combobox opens. role="listbox". This is what the guide covers.
Combobox
The user types to filter a list of suggestions. A text input paired with a listbox popup, focus staying in the input. See the combobox guide.
Why not a native <select multiple> for multi-select? It is a real, accessible listbox — but its user experience is notoriously poor: the box is small, users must hold Ctrl or Shift to pick more than one, a stray click wipes the whole selection, and on touch it is worse still. That awkwardness is why custom multi-select listboxes exist. Even so, a group of checkboxes usually beats both — reach for a custom listbox only when the option count makes checkboxes unwieldy.
The single clearest tell: would a handful of radio buttons or checkboxes fit on the page? If yes, use them — they are battle-tested and need no code. A listbox earns its cost only when the list is long enough to scroll, dynamic enough to need custom markup, or must behave as a single Tab stop. When in doubt, start with the native control and upgrade only if it genuinely will not do the job.
2. Anatomy: Roles, States, and Properties
A listbox is a small pattern with a strict rule at its centre: arole="listbox" may contain only role="option" children, or role="group" wrappers that themselves contain options — nothing else. Here is the full set of roles and attributes and what each part is for.
| Element / concept | Attribute | Purpose |
|---|---|---|
| The listbox container | role="listbox" + aria-label / aria-labelledby | The single Tab stop that holds the options; it needs an accessible name. Add aria-multiselectable="true" when more than one option can be selected, and aria-orientation="horizontal" for the rare side-by-side listbox (vertical is the default). |
| Each option | role="option" | One per choice. Carries aria-selected to say whether it is chosen, an aria-disabled when it cannot be selected, and — in the roving-tabindex model — the tabindex. Its accessible name should be its visible label. |
| Selected state | aria-selected | "true" on a chosen option, "false" on an unchosen one. Required on every selectable option in a multi-select listbox; on the single chosen option in a single-select one. This — not a colour — is what a screen reader announces. |
| Multiple selection | aria-multiselectable="true" | On the listbox, when the user may select more than one option. Its presence tells assistive technology to expect several selected options and changes the expected keyboard model (Space toggles, Shift+Arrow extends). |
| Disabled option | aria-disabled="true" | On an option that exists but cannot be chosen. Keep it reachable by the arrow keys so the user learns it is there, but never let it take aria-selected. |
| Option groups | role="group" + aria-label / aria-labelledby | Wraps a run of related options under a heading ("Fruit", "Vegetables"). The group needs its own accessible name so the screen reader can announce which group an option belongs to. |
| Roving focus | tabindex="0" / tabindex="-1" | In the roving-tabindex model, exactly one option has tabindex="0" and every other has tabindex="-1"; the arrow keys move the 0 and call focus(). |
| Virtual focus (alternative) | aria-activedescendant | In the aria-activedescendant model, DOM focus stays on the listbox and this attribute on it points at the id of the active option. Use this or roving tabindex — not both. |
An option is a leaf: its accessible name should be its visible text, and it should not contain its own interactive controls. If each row needs a button or a link of its own, you are describing a grid or a plain list, not a listbox. For how each role and property surfaces to assistive technology, see the ARIA roles & attributes reference.
3. The HTML Structure
Here is a single-select listbox built with a <ul> for the container and <li> for each option, using the roving-tabindex model. The listbox has an accessible name, exactly one option starts selected and in the Tab order, and the roles do the rest.
<label id="size-label">T-shirt size</label>
<ul
role="listbox"
aria-labelledby="size-label"
tabindex="-1" <!-- container is not the Tab stop; the option is -->
>
<li role="option" id="size-s" tabindex="-1">Small</li>
<!-- The one selected option, and the one in the Tab order to start. -->
<li role="option" id="size-m" tabindex="0" aria-selected="true">Medium</li>
<li role="option" id="size-l" tabindex="-1">Large</li>
<li role="option" id="size-xl" tabindex="-1" aria-disabled="true">
Extra Large (out of stock)
</li>
</ul>A <ul> with role="listbox" loses its list semantics — and that is expected. Once you put role="listbox" on the <ul> and role="option" on each <li>, the browser stops exposing them as a plain list and exposes the listbox widget instead. That is the whole point. Do not also try to keep the list semantics; a listbox is a widget, not a list of content.
The container carries the accessible name (here via aria-labelledby pointing at the visible label). Every option needs a stable id if you use the aria-activedescendant model in section 7; with roving tabindex the ids are optional. The disabled option keeps its place in the list — the user still arrows to it and hears it is out of stock — but it can never take aria-selected.
4. The Keyboard Model
This is the contract that turns a styled list into a listbox, and there is no native element to implement it for you when you build a custom one. A list that responds only to Tab and a click is not a listbox; it is a set of clickable divs.
| Key | Expected behavior |
|---|---|
| Down Arrow | Moves focus to the next option. In a single-select listbox, selection follows focus, so the newly focused option becomes the selected one. Stops at the last option — it does not wrap. |
| Up Arrow | Moves focus to the previous option (and, in single-select, moves the selection with it). Stops at the first option. |
| Home | Moves focus to the first option in the list. |
| End | Moves focus to the last option in the list. |
| Type a character | Type-ahead: moves focus to the next option whose label starts with the typed character. Typing several characters quickly matches a longer string. |
| Space | In a multi-select listbox, toggles the selected state (aria-selected) of the focused option. In a single-select listbox where selection does not follow focus, selects the focused option. |
| Shift + Down / Up Arrow | Multi-select only: moves focus to the next or previous option and toggles it into the selection, extending a contiguous range. |
| Ctrl + A | Multi-select only (optional but expected): selects all options, or clears the selection if everything is already selected. |
| Shift + Space | Multi-select only (optional): selects the contiguous run of options from the most recently selected option to the focused one. |
| Tab / Shift + Tab | Moves focus into and out of the whole listbox. The listbox is a single Tab stop; Tab never moves between the options — the arrow keys do that. |
The arrow keys stop at the edges — Down Arrow on the last option does nothing, it does not wrap to the top. For the wider keyboard contract every custom widget owes, and the roving-tabindex technique the next sections build on, see the keyboard accessibility guide and the focus management guide.
5. Single-Select: Selection Follows Focus
In a single-select listbox, exactly one option is chosen at a time, and the defining behaviour is that selection follows focus: as the user arrows from option to option, the newly focused option immediately becomes the selected one — aria-selected moves with the focus. It is the way a native <select>behaves while its list is open, and it is what a screen reader user expects from a “choose one” list.
const listbox = document.querySelector('[role="listbox"]')
const options = [...listbox.querySelectorAll('[role="option"]')]
let activeIndex = options.findIndex((o) => o.getAttribute("aria-selected") === "true")
function select(index) {
const option = options[index]
if (!option || option.getAttribute("aria-disabled") === "true") return
// Single-select: exactly one aria-selected="true" at a time.
options.forEach((o) => o.setAttribute("aria-selected", "false"))
option.setAttribute("aria-selected", "true")
// Roving tabindex: move the "0" and focus the new option.
options[activeIndex]?.setAttribute("tabindex", "-1")
option.setAttribute("tabindex", "0")
option.focus()
activeIndex = index
}
listbox.addEventListener("keydown", (e) => {
switch (e.key) {
case "ArrowDown": select(activeIndex + 1); break // selection follows focus
case "ArrowUp": select(activeIndex - 1); break
case "Home": select(0); break
case "End": select(options.length - 1); break
default: return // let type-ahead etc. run
}
e.preventDefault() // <- stop the arrows scrolling the page
})Do not make selection follow focus when selecting is expensive. If choosing an option fires a network request, navigates, or changes the page around it, moving the selection on every arrow press is jarring and can trip 3.2.1 On Focus. In that case, let the arrows move focus only and require a deliberate Enter or Space to commit — the same option the multi-select model uses. Selection-follows-focus is for cheap, side-effect-free choices.
Note the closing e.preventDefault(): without it the arrow keys move the selection and scroll the page. And because the code sets every option to aria-selected="false" before selecting one, the state can never drift into two options both claiming to be selected.
6. Multi-Select: Focus and Selection Are Separate
A multi-select listbox is marked with aria-multiselectable="true" on the listbox, and it flips the model of the previous section on its head: focus and selection are now separate. Arrowing moves focus without changing what is selected; the user presses Space to toggle the focused option, so any number of options can carry aria-selected="true" at once. Every selectable option gets an explicit true or false so a screen reader can announce the state of each as the user moves through.
<ul role="listbox" aria-label="Toppings" aria-multiselectable="true">
<li role="option" tabindex="0" aria-selected="true">Mushrooms</li>
<li role="option" tabindex="-1" aria-selected="false">Olives</li>
<li role="option" tabindex="-1" aria-selected="true">Peppers</li>
<li role="option" tabindex="-1" aria-selected="false">Onions</li>
</ul>listbox.addEventListener("keydown", (e) => {
const option = options[activeIndex]
switch (e.key) {
// Plain arrows move FOCUS only — the selection does not change.
case "ArrowDown": if (!e.shiftKey) { moveFocus(activeIndex + 1); break }
moveFocus(activeIndex + 1); toggle(options[activeIndex]); break
case "ArrowUp": if (!e.shiftKey) { moveFocus(activeIndex - 1); break }
moveFocus(activeIndex - 1); toggle(options[activeIndex]); break
// Space toggles the selected state of the focused option.
case " ": toggle(option); break
// Ctrl+A selects every option (or clears them if all are selected).
case "a": case "A":
if (e.ctrlKey) { toggleSelectAll(); break }
return
default: return // let type-ahead and other keys through
}
e.preventDefault()
})
// Toggle one option's aria-selected between "true" and "false".
function toggle(option) {
const selected = option.getAttribute("aria-selected") === "true"
option.setAttribute("aria-selected", String(!selected))
}Beyond Space, a full multi-select listbox supports Shift+Down/Up Arrow to extend a contiguous selection while moving focus, and Ctrl+A to select or clear everything — the conventions users already know from file managers and email clients. Because focus and selection are separate, the focus indicator and the selected styling must be visually distinct: a user needs to see both where they are and what they have chosen at the same time.
Multi-select is exactly where a checkbox group wins. Before you build this, ask whether the options would fit as a group of checkboxes in a <fieldset>. Checkboxes give you the toggle, the keyboard, the visible checked state, and the screen reader announcement for free, with no roving tabindex and no aria-multiselectable. Choose the multi-select listbox only when the list is long enough to need scrolling or a single Tab stop.
7. Roving Tabindex or aria-activedescendant?
A listbox is a composite widget: one Tab stop, with the arrow keys moving an internal focus. There are two ways to manage that internal focus, and for a standalone listbox the choice is genuinely open — which is what sets it apart from a combobox, whose popup is forced to use aria-activedescendant because focus has to stay in the text input.
Roving tabindex
- Real DOM focus moves onto the options.
- One option has
tabindex="0", the rest-1; you move the 0 and callfocus(). - You get the browser's own focus ring for free.
- The default choice for a standalone listbox.
aria-activedescendant
- DOM focus stays on the listbox container.
- An
aria-activedescendantattribute points at the active option'sid. - Nothing is really focused, so you supply the “active” style in CSS.
- Handy when the list is virtualised, or shared with a combobox.
<!-- The aria-activedescendant model: focus stays on the <ul>. -->
<ul
role="listbox"
aria-label="Country"
tabindex="0" <!-- the listbox itself is focusable -->
aria-activedescendant="country-fr" <!-- points at the active option -->
>
<li role="option" id="country-de">Germany</li>
<li role="option" id="country-fr" aria-selected="true">France</li>
<li role="option" id="country-it">Italy</li>
</ul>
<!-- On Arrow Down you move aria-activedescendant to the next id and move a
CSS ".active" class to match — you never call .focus() on an option. -->Prefer roving tabindex for a self-contained listbox: it is simpler, and the native focus indicator satisfies 2.4.7 Focus Visible without extra CSS. Reach for aria-activedescendant when the list is virtualised (options come and go from the DOM as the user scrolls) or when the same component also powers a combobox where focus must live in the input. See focus management for both models side by side.
8. Grouped and Disabled Options
When a list is long, you can cluster related options under headings — the analog of the native <optgroup>. Wrap each cluster in a role="group" that has its own accessible name, so a screen reader can announce which group an option belongs to. The group label itself is not an option — the user never lands on it, and it can never be selected.
<ul role="listbox" aria-label="Choose an ingredient">
<li role="group" aria-labelledby="grp-fruit">
<span id="grp-fruit">Fruit</span>
<ul>
<li role="option" tabindex="0" aria-selected="true">Apricot</li>
<li role="option" tabindex="-1">Cherry</li>
</ul>
</li>
<li role="group" aria-labelledby="grp-veg">
<span id="grp-veg">Vegetables</span>
<ul>
<li role="option" tabindex="-1">Carrot</li>
<li role="option" tabindex="-1" aria-disabled="true">Kale (sold out)</li>
</ul>
</li>
</ul>For a disabled option, set aria-disabled="true"and skip it when the user tries to select — but keep it in the arrow-key order. That last point is deliberate: if you remove a disabled option from the keyboard path entirely, a screen reader user never learns it exists, and “Kale, sold out” is useful information. Let them arrow onto it and hear it; just never let it become selected.
A disabled option must still meet contrast where it carries meaning. Greying out is fine, but if the disabled label conveys information the user needs — why it is unavailable — keep its text readable rather than fading it below 1.4.3 Contrast. Disabled is not a licence to make text invisible.
9. Listboxes in React
In React you render the options from data and keep the active option and the selection in state, but the accessibility contract does not change: the roles, the roving tabindex, the keyboard handler, and aria-selected are all still yours to get right. Here is a single-select listbox where selection follows focus.
function Listbox({ label, options }) {
const [active, setActive] = useState(0) // index of the focused/selected option
const refs = useRef([])
function move(next) {
if (next < 0 || next >= options.length) return // stop at the edges
setActive(next)
refs.current[next]?.focus() // roving tabindex + focus
}
function onKeyDown(e) {
const map = { ArrowDown: active + 1, ArrowUp: active - 1,
Home: 0, End: options.length - 1 }
if (!(e.key in map)) return
move(map[e.key])
e.preventDefault()
}
return (
<ul role="listbox" aria-label={label} onKeyDown={onKeyDown}>
{options.map((opt, i) => (
<li
key={opt.id}
ref={(el) => (refs.current[i] = el)}
role="option"
tabIndex={i === active ? 0 : -1}
aria-selected={i === active} {/* single-select: follows focus */}
onClick={() => move(i)}
>
{opt.label}
</li>
))}
</ul>
)
}For production, lean on a well-tested implementation rather than hand-rolling the edge cases: React Aria's useListBox / ListBox gives you single- and multi-select, type-ahead, roving focus, and the full keyboard model already handled; Headless UI's Listbox and Radix Select cover the common select-style cases. The same principles carry to other frameworks — see the React, Vue, and Angular guides. Whatever you ship, verify it against the workflow below rather than trusting the README.
How to Test an Accessible Listbox
Automated tools catch a missing role or an option with no accessible name, but almost everything that decides whether a listbox is usable — the arrow navigation, the selection model, the type-ahead — is a hands-on check that takes a couple of minutes.
- Tab exactly once. Focus should land on the listbox or its active option, and one more Tab should leave the listbox entirely. If Tab steps option by option, you have the every-option-
tabindex=0bug and no roving tabindex. - Arrow, jump, and type. Arrow up and down, press Home and End, and type a letter to confirm type-ahead jumps to a matching option. The page must not scroll while you do it, and the arrows must stop at the ends rather than wrap.
- Exercise the selection model. In single-select, confirm selection follows focus (or that Enter commits it, if you chose that). In multi-select, confirm Space toggles the focused option and Shift+Arrow extends the selection.
- Listen with a screen reader. Each option should announce its label, its role, its position in the set, and whether it is selected; the listbox should announce its name and, for multi-select, that several options can be chosen. The screen reader testing guide has the commands for NVDA and VoiceOver.
- Measure the visuals. The focus indicator and the selection indicator each reach 3:1 (1.4.11) and are distinct from each other; selection is not colour alone (1.4.1); each option is at least 24 × 24 px as a pointer target (2.5.8).
Layer automated checks on top with axe-core, and see automated vs manual testing for where each fits. Scan the live page with the URL accessibility auditor to catch a missing name, role, or state before it ships.
Common Listbox Mistakes & How to Fix Them
| Anti-pattern | Why it fails | The fix |
|---|---|---|
| Options are <div>s or <li>s with click handlers, not role="option" inside a role="listbox". | A screen reader announces plain text with no role, no position in a set, and no selected state, so the user cannot tell it is a choosable list or what they have chosen (1.3.1, 4.1.2). | Use a container with role="listbox" and children with role="option", each exposing aria-selected. |
| Every option carries tabindex="0". | The listbox stops being a single Tab stop, so a keyboard user must Tab through every option to get past it and the arrow-key model never engages — a 200-option list becomes 200 Tab stops (2.1.1, 2.4.3). | Use roving tabindex: one option at tabindex="0", every other at tabindex="-1", moved by the arrow keys. Or use aria-activedescendant instead. |
| A multi-select listbox is built where a labelled group of checkboxes would do. | It reinvents keyboard support, selection state, and focus management that native checkboxes give for free, and it is less familiar to screen reader users — more code and more risk for no benefit. | For a small, fully visible set of "choose several" options, use checkboxes in a <fieldset> with a <legend>. Reserve the listbox for long, scrolling, or single-Tab-stop lists. |
| The selected option is shown only by a background colour, with no aria-selected. | A screen reader user never learns which option is chosen, and colour alone fails colour-blind users (1.4.1, 4.1.2). | Set aria-selected on the option and pair the colour with a non-colour cue such as a checkmark, a checkbox, or a bold outline. |
| DOM focus moves onto an option but aria-activedescendant is never updated (or vice versa). | The two focus models are mixed, so the screen reader's idea of the active option drifts from the visible one and the user is told the wrong thing (4.1.2). | Pick one model. With roving tabindex, move tabindex and call focus(). With aria-activedescendant, keep focus on the listbox and move the attribute — never both. |
| Interactive controls — links, buttons, checkboxes — are nested inside role="option" elements. | A listbox option is a leaf; controls inside it are not reliably reachable or operable, and the option's own name becomes ambiguous (4.1.2). | Keep options as plain labels. If each row needs its own controls, you want a grid or a list of items, not a listbox — see the data grid guide. |
Accessible Listbox Checklist
- Right control. The options are long, scrolling, or must be one Tab stop. If a few radios or checkboxes would fit, use those instead.
- Roles and name.
role="listbox"with an accessible name, androle="option"on every choice — options only, no nested controls (1.3.1, 4.1.2). - One Tab stop. Roving tabindex (one option at
0, the rest-1) oraria-activedescendant— one model, not both (2.4.3). - Full keyboard. Up/Down, Home/End, and type-ahead, with
preventDefaultso the page does not scroll (2.1.1). - Selection model. Single-select: one
aria-selected, selection follows focus. Multi-select:aria-multiselectableon the listbox, an explicittrue/falseon every option, Space to toggle. - Selection shown, not just coloured. A checkmark, checkbox, or outline alongside the colour, and the focus indicator distinct from the selection indicator (1.4.1, 1.4.11).
- Disabled options handled.
aria-disabledon unavailable options, still reachable by the arrows, never selectable, readable where the label carries meaning. - Reachable targets. Each option is at least 24 × 24 px as a pointer target (2.5.8).
Work through the full WCAG 2.2 checklist to see the listbox in the context of every other requirement.
Check Your Listbox on a Live Page
Scan any page with our free axe-core-powered auditor to catch a listbox with a missing role, options with no accessible name, or a selected state that lives only in CSS — then run the Tab, arrow-key, and Space passes above for the failures no scanner can see.
Frequently Asked Questions
What is a listbox, and when should I use role="listbox"?▾
A listbox is a widget that presents a set of choices the user selects from — one option in a single-select listbox, or several in a multi-select one. In ARIA it is a container with role="listbox" holding children with role="option", and the user moves a roving focus through the options with the arrow keys and selects with the state shown by aria-selected. Use it when you need a compact, scrollable list of choices that behaves as a single Tab stop — a settings picker, a list of items to act on, the popup a combobox opens. Do not reach for it as a default: for a handful of visible choices, native radio buttons (choose one) or checkboxes (choose several) are more familiar, need no JavaScript, and are harder to get wrong. A listbox earns its complexity when the options are many, need to scroll, or must live in one Tab stop.
What is the difference between a listbox and a combobox?▾
A listbox is the list of options itself; a combobox is a text input paired with a popup — and that popup is usually a listbox. The crucial difference is where keyboard focus lives. In a standalone listbox, DOM focus moves into the list and onto the options (or a roving tabindex does), so the arrow keys act directly on the list. In a combobox, focus must stay in the input so the user can keep typing, so the list cannot take real focus; instead the input uses aria-activedescendant to point at the active option while the caret never leaves the input. So a listbox can use either the roving-tabindex model or the aria-activedescendant model, whereas a combobox's popup is forced to use aria-activedescendant. If your control has a text field the user types into to filter, you want the combobox pattern; if it is a list they arrow through and select from directly, you want this listbox pattern.
Should I use a listbox or a group of checkboxes or radio buttons?▾
For most "choose one" and "choose several" problems, native radio buttons and checkboxes are the better answer. They are built into HTML, need no ARIA and no JavaScript for their semantics or keyboard support, are instantly familiar to every screen reader user, and cannot drift out of sync the way hand-built ARIA state can. A group of checkboxes in a labelled fieldset is almost always clearer than a multi-select listbox for a small, fully visible set of options. Reach for role="listbox" specifically when a native control does not fit: the options are numerous and need to scroll inside a fixed height, the whole control must be a single Tab stop rather than one stop per option, or you are building the popup for a combobox or another composite widget. In short, a listbox is a specialised tool — prove that radios or checkboxes will not do the job before you take on the roving focus, the selection model, and the keyboard code a listbox requires.
What is the difference between single-select and multi-select listboxes?▾
In a single-select listbox exactly one option is selected at a time, and selection normally follows focus: as the user arrows from option to option, the newly focused option becomes the selected one, so aria-selected="true" moves with the focus, much like a native select does when its list is open. In a multi-select listbox — marked with aria-multiselectable="true" on the listbox — focus and selection are separate: arrowing moves focus without changing what is selected, and the user presses Space to toggle the selected state of the focused option, so several options can carry aria-selected="true" at once. Because they behave so differently, decide up front which one you are building. A good rule: make selection follow focus only in single-select, and only when selecting an option has no expensive side effect; if choosing an option triggers something costly, require an explicit Enter or Space to commit instead.
What keyboard interactions does an accessible listbox need?▾
The listbox is one Tab stop. Once focus is inside, Down Arrow and Up Arrow move focus to the next and previous option, Home moves to the first option and End to the last, and typing a character (type-ahead) moves focus to the next option whose label starts with that character. In a single-select listbox, moving focus also moves the selection. In a multi-select listbox, Space toggles the selected state of the focused option, Shift+Down Arrow and Shift+Up Arrow extend a contiguous selection while moving focus, and Ctrl+A selects or clears all options. Tab and Shift+Tab move focus into and out of the whole listbox, never between its options. Every one of these must work with no pointer, or the listbox fails 2.1.1 Keyboard. The arrow keys stop at the first and last option — they do not wrap — and you must call preventDefault on the keys you handle so the page does not scroll underneath the list.
Should a listbox use roving tabindex or aria-activedescendant?▾
Both are valid for a standalone listbox, and the choice is genuinely open — unlike a combobox popup, which must use aria-activedescendant because focus stays in the input. With roving tabindex, real DOM focus moves onto the options: exactly one option carries tabindex="0" and the rest carry tabindex="-1", and you move the 0 and call focus() as the user arrows. It gives you the browser's own focus ring for free and is the most robust choice for a self-contained listbox. With aria-activedescendant, DOM focus stays on the listbox container and an aria-activedescendant attribute on it points at the id of the active option, which you move as the user arrows; nothing in the list is ever really focused, so you must supply the visible "active option" style yourself in CSS. Prefer roving tabindex for a standalone listbox for its simplicity and native focus indicator; reach for aria-activedescendant when the list is virtualised, or when the same code also has to power a combobox where focus must remain elsewhere.
How do I mark options as selected and disabled?▾
Selection is carried by aria-selected on each option — never by a CSS class or background colour alone. In a single-select listbox, put aria-selected="true" on the one chosen option; the others need no aria-selected, or you can set it to "false". In a multi-select listbox, put aria-multiselectable="true" on the listbox and give every selectable option an explicit aria-selected of "true" or "false" so a screen reader can announce the state of each. A disabled option carries aria-disabled="true"; keep it in the arrow-key order so the user still learns it exists, but do not let it become selected. Whatever the visual design, pair the selected state with a non-colour cue — a checkmark, a filled checkbox, a bold outline — so the choice survives 1.4.1 Use of Color, and make sure the option's accessible name is its visible label so speech-input users can call it by the word they see.
How do I test a listbox for accessibility?▾
Start with the keyboard and no mouse. Tab once — focus should land on the listbox (or its active option), and one more Tab should leave the listbox entirely; if Tab steps option by option, you have the every-option-tabindex-0 bug and no roving tabindex. Arrow up and down through the options, press Home and End, and type a letter to confirm type-ahead jumps to a matching option, checking the page never scrolls out from under you and the arrows stop at the ends rather than wrap. In a single-select listbox, confirm selection follows focus; in a multi-select one, confirm Space toggles the focused option and Shift+Arrow extends the selection. Then listen with a screen reader: each option should announce its label, its role as an option, its position in the set, and whether it is selected, and the listbox should announce its own name and, for multi-select, that more than one option can be chosen. Confirm selection is exposed through aria-selected and shown by more than colour, the focus indicator is clearly visible and reaches 3:1 contrast, and any pointer target is at least 24 by 24 CSS pixels. Layer axe-core on top for the mechanical checks, but the keyboard and screen reader passes are what decide whether the listbox actually works.
Essential Accessibility Resources
Comprehensive tools, checklists, and guides to help you create inclusive digital experiences