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.

The parts of a multi-select combobox Hierarchy of a multi-select combobox: a labelled text input with role=combobox, a listbox popup of options, and a group of selected-value chips. The parts of a multi-select comboboxinput role="combobox"aria-expanded, aria-controls, aria-activedescendantul role="listbox"the filtered option list, one optionper itemli role="option"aria-selected="true" or "false" onevery optionSelected chipseach a button that removes its value
Three regions, three jobs — and the input owns all the state attributes.

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.

The combobox key contract Keyboard contract for a multi-select combobox: arrows move the active option, Enter toggles it, Escape closes, and Backspace removes the last chip. The combobox key contractDown / Up arrowMove the active optionaria-activedescendant points at it; theoption is readEnterToggle the active optionAnnounce the option and its new state,list stays openEscapeClose the listFocus stays in the input; announce theselected countBackspace on empty inputRemove the last selected valueAnnounce which value was removedTabLeave the comboboxCloses the list and commits the selection
The list stays open after a selection — that is what makes it multi-select.

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
Selection state on options Comparison of aria-selected used correctly on listbox options against aria-checked, which belongs to checkbox semantics. Selection state on options✓ Correctrole="option" with aria-selected="true"aria-multiselectable="true" on the listboxThe input keeps aria-activedescendant✗ Incorrectrole="option" with aria-checkedCheckbox inputs inside listbox optionsMoving DOM focus into the option list
A listbox option is selected, not checked — the roles are not interchangeable.

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.

Permalink to "Related"

Back to Accessible Filtering & Faceted Search