Accessible Multi-Select Combobox for Column Filters
Permalink to "Accessible Multi-Select Combobox for Column Filters"Once a facet has more than a dozen options, a checkbox list stops working by ear: a screen reader user has to listen through every option to find the one they want. A filterable multi-select combobox solves it — and introduces the most intricate ARIA wiring in a typical data interface. This page covers that wiring: which element owns which attribute, why the list must stay open after a selection, and how the selected values are represented so the user can review them without reopening anything.
It is the control detail beneath accessible filtering and faceted search, and it uses the focus model described in using aria-activedescendant for grid cell focus.
Spec reference
Permalink to "Spec reference"The ARIA combobox pattern pairs an input with a popup. The input carries role="combobox", aria-expanded reflecting whether the popup is showing, and aria-controls pointing at the popup’s id. When the popup is a listbox, the input also carries aria-activedescendant naming the currently active option — DOM focus never leaves the input.
Options carry role="option" and aria-selected. For a multi-select, the listbox carries aria-multiselectable="true", which tells the reader that more than one option can be selected and changes how it phrases the state.
aria-checked is not part of this pattern. It belongs to checkbox, radio and menuitemcheckbox roles. Using it on an option produces an element whose role and state contradict each other, and readers handle that inconsistently — usually by ignoring the state entirely.
When to use — and when not to
Permalink to "When to use — and when not to"Use a multi-select combobox for facets with many options where the user knows roughly what they are looking for: accounts, tags, product codes, countries.
Do not use it for two or three options. A fieldset of checkboxes is simpler, needs no JavaScript, and is more robust in every assistive technology.
Do not use it where the options are not searchable by name — a set of icons, a colour palette. Filtering by typing is the reason the pattern earns its complexity, and without it you have built an inaccessible listbox.
Annotated code example
Permalink to "Annotated code example"<!-- SC 4.1.2: the input owns role, expanded state and the active option pointer -->
<label for="region-filter">Region</label>
<input id="region-filter"
role="combobox"
type="text"
autocomplete="off"
aria-expanded="false"
aria-controls="region-listbox"
aria-describedby="region-hint">
<p id="region-hint" class="visually-hidden">
Type to filter. Use arrow keys to move, Enter to select. Selection stays open.
</p>
<ul id="region-listbox" role="listbox" aria-multiselectable="true" hidden>
<li id="opt-nordics" role="option" aria-selected="false">Nordics (210)</li>
<li id="opt-dach" role="option" aria-selected="false">DACH (164)</li>
</ul>
<!-- SC 2.4.3: the selected set, reviewable without reopening the list -->
<ul class="chips" aria-label="Selected regions"></ul>
// SC 4.1.2: toggling updates the option state; the list stays open
function toggleOption(option) {
const selected = option.getAttribute('aria-selected') === 'true';
option.setAttribute('aria-selected', String(!selected));
syncChips(); // rebuild the chip list
runFilterQuery(); // debounced, announces the count
// No announcement here — the option's own state change is read by the reader
}
input.addEventListener('keydown', (e) => {
switch (e.key) {
case 'ArrowDown': move(1); break;
case 'ArrowUp': move(-1); break;
case 'Enter': toggleOption(activeOption()); break;
case 'Escape': close(); announce(`${selectedCount()} regions selected`); break;
case 'Backspace': if (!input.value) removeLastChip(); break;
default: return;
}
e.preventDefault();
});
function move(delta) {
const next = nextVisibleOption(delta);
// DOM focus stays on the input; the pointer is what moves
input.setAttribute('aria-activedescendant', next.id);
next.scrollIntoView({ block: 'nearest' }); // the browser will not do this for you
}
The scrollIntoView call is easy to forget and produces a very confusing bug: the reader announces an option that is not visible, because moving aria-activedescendant does not scroll anything. Sighted keyboard users see the list apparently stop responding.
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Assistive technology | On arrow | On Enter | Deviation |
|---|---|---|---|
| NVDA + Firefox | Reads the option and its selected state | Reads the new state | Reads the option count on open |
| JAWS + Chrome | Reads the option and position in set | Reads the new state | May re-read the input label after several moves |
| VoiceOver + Safari | Reads the option | Reads the new state after a short delay | aria-multiselectable phrasing differs from the Windows readers |
| TalkBack + Chrome | Reads the option on swipe | Reads the new state | The pattern is awkward on touch; chips carry most of the value |
Integration context
Permalink to "Integration context"The combobox is one facet among several, and its selections feed the same query and the same count announcement as every other facet — described in announcing filter result counts with aria-live. Do not announce the result count from inside the combobox; let the shared debounced handler do it, or the user hears two overlapping messages per selection.
Chips are shared too. A chip removed from the combobox’s selected set is the same chip component described in clearing filters without losing keyboard focus, with the same focus obligations.
Gotchas
Permalink to "Gotchas"Focus moved into the list. Moving DOM focus to the option breaks the typing path — the user can no longer filter. Keep focus on the input and move aria-activedescendant instead.
The active option is not scrolled into view. Covered above; call scrollIntoView on every move.
aria-expanded left stale. Every open and close path — Escape, Tab, click outside, selection of the last matching option — must update it. A stale aria-expanded="true" on a closed list makes the reader describe a popup that is not there.
The hint disappears. A aria-describedby hint that is removed once the user starts typing takes the instructions with it. Keep it in the DOM.
FAQ
Permalink to "FAQ"When should a facet become a combobox instead of a checkbox list?
Around a dozen options. Below that, a checkbox group inside a fieldset is simpler, fully native and easier for everyone. Above it, a screen reader user has to listen through every option to find one, which is where a filterable combobox starts to win. The threshold is about listening time, not screen space.
Should the option list close after each selection?
No. Closing after every selection makes multi-select painful, because the user must reopen the list for each value. Keep it open, keep the active option where it was, and announce the toggled option with its new state. Close on Escape, on Tab, or on a click outside.
How do selected values get announced?
Two ways, and both are needed. Each toggle announces the option and its state — “Nordics, selected” — from the option itself. And the selected set is represented as removable chips, each a button named “Remove Nordics”, so a user can review and change the selection without reopening the list.
Related
Permalink to "Related"- Accessible filtering & faceted search — the parent guide and when to choose this control
- Announcing filter result counts with aria-live — the shared count message
- Using aria-activedescendant for grid cell focus — the same focus model in a grid