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

When not to use it

Keyboard interactions

Role listbox, verified at WCAG 2.2-AA.

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={[]} />

See the full MultiSelect reference →