Components
Multi-select
A combobox that keeps its list open while you pick several things. We have exactly one place for it — filtering by rating category — and that constraint is what keeps it from becoming a tag input, a token field and a search box at the same time.
Live
Filter by category
Showing all 412 reviews.
Behaviour
| Situation | Behaviour | Why |
|---|---|---|
| An option is chosen | The list stays open and the option stays in place with a check | Closing after each pick makes selecting three things a nine-action job |
| An option is chosen | It is not removed from the list | Removing it makes the list jump under the pointer |
| The field has text | Options filter; the query clears on select | The next pick starts from the whole list |
| Backspace on an empty field | Removes the last chip | The one keyboard shortcut people already expect here |
| More than 1 selected | The chips collapse to a single count — 4 categories selected | The control must stay one line tall; two long names plus the input already overflow it |
| A collapsed selection | Individual removal moves to the list, where every option is a toggle | A count chip has nothing to remove; the list already shows what is selected |
| Nothing selected | Placeholder reads All categories | Empty means unfiltered, and must say so |
| Escape | Closes the list, keeps the selection | Escape dismisses the popup, it does not undo work |
| Blur | Closes; selection is already applied | There is no Apply button and no pending state |
Selection applies immediately. A multi-select with an Apply button is two mental models at once — people tick, look at the results, see nothing change, and tick again.
Specification
| Property | Value | Why |
|---|---|---|
| Control | role="combobox" + aria-expanded + aria-controls | The APG combobox pattern, unmodified |
| List | role="listbox" aria-multiselectable="true" | Announces that more than one is allowed, before the first pick |
| Options | role="option" + aria-selected | Selected state read on arrival, not inferred from a tick |
| Focus | Stays in the input; aria-activedescendant moves | Typing must keep working while arrowing |
| Row height | 44px | The floor, met by the row rather than by the tick |
| Chip | One named chip with a 24px remove button; a count chip beyond one | The named chip is a target and its remove button is a second one |
| Selected cue | A check, plus --color-primary-subtle | Colour is never the only signal |
| Announcement | aria-live="polite" count after each change | “3 of 7 categories selected” |
When not to use it
- Under 8 options, all visible at once — use a checkbox group. It shows every option without a click and needs no ARIA at all. Our seven categories sit right on this line, and the multi-select earns its place only because the filter bar cannot afford seven rows.
- Two or three options that need explaining — use a choicebox.
- Free text as well as options — that is a tag input, and we do not have one. Do not grow this component into it.
- One choice only — that is Select, which closes on pick and behaves differently on purpose.
Rules
- Order never changes. Selected options do not float to the top, ever — the list is a stable map, and rearranging it under the pointer causes mis-clicks.
- Empty means everything. No selection is not an empty result set; it is an unfiltered one, and the placeholder must say so.
- Clear-all is one control, labelled, and it appears only when something is selected. Past one selection it is the only control in the field that removes anything — everything else happens in the list.
- Never lose a selection on re-render. Refiltering the underlying data must not clear chips.
- The count is announced, not just drawn. Chips are visual; the live region is what a screen-reader user has.
React
From @sportingscouter/ui-react. The component adds no visual values of its own — every class comes from the same tv() recipe the design system defines, so the two cannot drift.
import { MultiSelect } from '@sportingscouter/ui-react';
<MultiSelect
label="Categories to compare"
placeholder="Choose any"
options={CATEGORIES}
value={picked}
onValueChange={setPicked}
maxChips={3}
/>
{/* The list STAYS OPEN on toggle — closing after one pick
forces a reopen per value, which is the thing a
multi-select exists to avoid. Backspace on the closed
trigger removes the last chip. */}Sporting Scouter Design System
Every token on this site is generated from
theme.css.