How to build an accessible MultiSelect in React
The trigger is a button with aria-haspopup="listbox", aria-expanded and aria-controls pointing at the panel. Inside the panel the search field carries role="combobox" with aria-expanded, aria-controls and aria-autocomplete="list", and owns aria-activedescendant — it is the element that holds DOM focus, so assistive technology tracks the active option as the arrows move it. With searchable={false} the listbox itself takes focus and the same attributes. The listbox is aria-multiselectable with role="option" rows carrying aria-selected, aria-disabled for unavailable ones and role="group" headings for grouped options; the search field, the select-all row and the loading/no-results message are siblings of the listbox rather than children, because a listbox owns only option and group children. ArrowUp/ArrowDown move the active option and skip disabled rows, Home/End jump to the ends, PageUp/PageDown move by ten, Enter toggles (Space too when there is no search field), Backspace removes the last chip from an empty search, and Escape closes the panel and returns focus to the trigger. A permanently mounted polite live region reports the selection count, so the first selection is announced as well as later ones. Selection, the active row and the focus ring each carry a non-colour channel under forced-colors, and every control reaches the coarse-pointer target minimum.
When to use a MultiSelect
- Selecting several values at once from a known list of options
- Lists long enough that the built-in search/filter helps the user find options
- Cases needing a compact trigger that summarizes the selected count, or chips per value
- Server-driven option lists, via onSearchChange plus filter={() => true}
When not to use it
- Choosing exactly one value — use Select
- Free-text entries with no option list behind them — use TagsInput
- A handful of always-visible options — use a Checkbox group
Keyboard interactions
Role listbox, verified at WCAG 2.2-AA.
ArrowDownArrowUpHomeEndPageUpPageDownEnterSpaceEscapeBackspace
Common mistakes
Avoid: <MultiSelect value={value} /> with no onValueChange
Prefer: <MultiSelect defaultValue={value} onValueChange={setValue} />
Passing value makes the component controlled for its whole life; without onValueChange the selection can never change. Use defaultValue when the component should own the state.
Avoid: <MultiSelect options={remote} onSearchChange={search} />
Prefer: <MultiSelect options={remote} onSearchChange={search} filter={() => true} />
The built-in matcher still runs over the server’s results and filters them a second time against the same query, hiding rows the server deliberately returned.
Example
<MultiSelect options={[{label:'One',value:'1'},{label:'Two',value:'2'}]} defaultValue={[]} />