Multi-select
Pick several. Selections become removable tokens inside the field, so the chosen set stays readable without reopening anything.
Also called Tag Picker, Token Input, Tags Input, Chips Input — in this system all of them are Multi-select.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
Token input with creation
Recipients, tags, labels. Enter or comma commits, the × removes, Backspace on an empty field removes the last one, and a pasted list becomes several tokens at once.
Multi-select or checkboxes
Below about six options, checkboxes win outright — every choice is visible, nothing is hidden behind a click, and the state is readable at a glance.
Token overflow
Cap the visible tokens and count the rest. A field that wraps to four lines pushes the whole form down and makes the layout jump on every selection.
Select all and clear
Past about ten options, both are worth their space. Selecting nine of ten is one click plus one removal instead of nine clicks.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
Applies to every project in this team.
Three tokens, an overflow counter, and a chevron. The panel stays open while the user picks, so choosing four permissions is one opening.
- Field height36px min, grows to 3 lines
Matches a text field when empty so a form row stays aligned. It may grow, but a hard ceiling stops the layout jumping on every selection.
- Token24px, pill, removable
A small Chip. Smaller than the free-standing chip so several fit on one line inside a 36px field.
- Token gap6px
Tight enough that the set reads as one value, wide enough that two adjacent remove buttons are not mis-tapped.
- Overflow counter“+9 more”, not a token
Deliberately not removable and not a chip, so it never reads as a selection that can be deleted.
- Checkmark gutter14px, always reserved
Reserved on every row, selected or not, so labels stay on one left edge and rows do not shift as they are toggled.
- Panel behaviourStays open on select
The defining difference from a Select. Closing after each pick makes four selections cost four openings.
- CountLive region under the field
"4 of 5 selected". The only feedback a non-visual user gets that a toggle landed.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| --ds-surface-inset | — | Field fill |
| --ds-border-interactive | — | Field border |
| --ds-accent | — | Focus border |
| --ds-accent-subtle | — | Token fill |
| --ds-accent-text | — | Token label and checkmark |
| --ds-layer-active | — | Overflow counter background |
| --ds-surface-overlay | — | Panel surface |
Spacing
| Token | Value | Used for |
|---|---|---|
| token gap | Between tokens | |
| field padding | Reduced from a text field to make room for tokens |
Radius
| Token | Value | Used for |
|---|---|---|
| full | — | Token shape |
| --radius-md | Field corners |
Shadow
| Token | Value | Used for |
|---|---|---|
| --shadow-e4 | — | Panel elevation |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Padding | Radius | Label gap | Type | Min width | When to use |
|---|---|---|---|---|---|---|---|
| Small | 32px min | 4px | — | 4px | 13px | — | Table filters and dense forms. |
| Medium | 36px min | 6px | — | 6px | 15px | 14rem | The default. |
| Large | 44px min | 8px | — | 6px | 16px | — | Touch layouts and recipient fields. |
| Token | 24px | 0 4px 0 8px | full | — | 12px | — | Smaller than a standalone Chip so several fit inside the field. |
| Field ceiling | 3 lines of tokens | — | — | — | — | — | Past this, overflow into a counter. Four lines pushes the rest of the form down the page. |
if (e.key === 'Backspace' && draft === '')
removeLast()a@x.com, b@x.com→a@x.comb@x.comaria-label="Remove Read deployments"Not a checklist to run at the end. These are the requirements the component was built from.
Contrast
- Token labels owe 4.5:1 against the token fill — they are the value of the field.
- The remove × must reach 3:1 against the token fill. At 11px inside a tint this is the easiest thing on the page to under-contrast.
- The checkmark on a selected row must not be the only signal. Pair it with a text change or the row reads identically in greyscale.
- The overflow counter is content and owes 4.5:1 — it tells the user something is hidden.
Keyboard
| ↓ | Opens the panel and moves the highlight to the first option. Focus stays in the field. |
| Space / Enter | Toggles the highlighted option. The panel does not close. |
| Backspace | On an empty query, removes the last token. The behaviour everyone expects from an email field. |
| ← / → | Optional roving focus across the tokens, so Tab does not stop nine times. |
| Delete | On a focused token, removes it and moves focus to the next one. |
| Esc | Closes the panel without changing the selection. |
| Tab | Leaves the field entirely. Tokens must not each be a tab stop. |
Screen readers
- Announce the count on every change: "Read deployments removed, 3 of 5 selected".
- The field announces its whole value on focus, so a user landing on it hears what is already chosen rather than an empty combobox.
- In a token input, announce the instruction via aria-describedby — the Enter-or-comma behaviour is invisible otherwise.
Focus & touch
- Removing a token must move focus to the next token, or to the input if it was the last — never to the body. Tokens are not individual tab stops; use roving focus across them so Tab crosses the field in one press.
- Remove buttons need a 24px minimum inside the token and the field needs 44px overall. On a phone the panel should become a full-screen sheet: a floating panel plus an on-screen keyboard plus a wrapping token field leaves almost no room for the options themselves.
| Attribute | Applied to | Notes |
|---|---|---|
| role="combobox" | The input | With aria-multiselectable on the listbox and aria-expanded on the input. |
| aria-activedescendant | The input | Tracks the highlighted option without moving DOM focus, exactly as in a single Combobox. |
| aria-selected | Each option | Not aria-checked. In a multi-selectable listbox, selected is the correct state. |
| aria-label | Each remove button | Must include the value: "Remove Read deployments". Nine buttons called "Remove" is nine identical controls. |
| aria-live="polite" | The selection count | "4 of 5 selected". Without it, toggling an option produces no feedback at all. |
| aria-describedby | The field | Points at the instruction — "Enter or comma to add" — which otherwise exists only visually. |
Example usage
1import { MultiSelect } from '@/ui/Select'23<Field label="Permissions" description="Applies to every project in this team.">4 <MultiSelect5 options={scopes}6 values={values}7 onChange={setValues}8 maxVisible={3} // cap the tokens; count the rest9 aria-label="Permissions"10 />11</Field>1213{/* The only feedback a non-visual user gets that a toggle landed. */}14<p aria-live="polite" className="sr-only">15 {values.length} of {scopes.length} selected16</p>1718// Token input. Three behaviours, all of them expected, none of them free.19<input20 value={draft}21 onChange={(e) => setDraft(e.target.value)}22 onKeyDown={(e) => {23 if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); commit() }24 // Learned from every email client ever shipped.25 if (e.key === 'Backspace' && draft === '') removeLast()26 }}27 onBlur={commit} // never silently lose what they typed28 onPaste={(e) => {29 const text = e.clipboardData.getData('text')30 if (!/[,\n;]/.test(text)) return31 e.preventDefault()32 add(text.split(/[,\n;]+/).map((s) => s.trim()).filter(Boolean))33 }}34/>3536// Removing must land focus somewhere sensible, never on <body>.37function remove(id: string, index: number) {38 setValues((v) => v.filter((x) => x !== id))39 ;(tokenRefs.current[index + 1] ?? inputRef.current)?.focus()40}Framework-free HTML
<div class="ds-multiselect">
<!-- Tokens are not individual tab stops: roving focus across them. -->
<span class="ds-token">
Read deployments
<button type="button" aria-label="Remove Read deployments">
<svg aria-hidden="true">…</svg>
</button>
</span>
<span class="ds-token__more">+9 more</span>
<input
role="combobox"
aria-expanded="true"
aria-controls="perm-list"
aria-activedescendant="perm-2"
aria-describedby="perm-hint perm-count"
/>
</div>
<ul id="perm-list" role="listbox" aria-multiselectable="true" aria-label="Permissions">
<!-- aria-selected, not aria-checked: this is a listbox. -->
<li id="perm-2" role="option" aria-selected="true">
<svg aria-hidden="true">…</svg> Read deployments
</li>
</ul>
<p id="perm-hint" class="sr-only">Enter or comma to add.</p>
<p id="perm-count" class="sr-only" role="status" aria-live="polite">4 of 5 selected</p>CSS
.ds-multiselect {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
min-block-size: 36px; /* matches a text field when empty */
/* Ceiling: past three lines the field pushes the rest of the form down
the page and the submit button moves while the user is aiming at it. */
max-block-size: calc(3 * 30px + 12px);
overflow-y: auto;
padding: 6px;
border: 1px solid var(--ds-border-interactive);
border-radius: var(--radius-md);
background: var(--ds-surface-inset);
}
.ds-multiselect:focus-within {
border-color: var(--ds-accent);
box-shadow: 0 0 0 3px var(--ds-accent-subtle);
}
.ds-token {
display: inline-flex;
align-items: center;
gap: 4px;
block-size: 24px; /* smaller than a standalone Chip */
padding-inline: 8px 4px;
border-radius: 999px;
background: var(--ds-accent-subtle);
color: var(--ds-accent-text);
font-size: 12px;
}
.ds-token button { inline-size: 16px; block-size: 16px; }
/* Not a token: it must never read as a selection that can be removed. */
.ds-token__more {
padding-inline: 8px;
border-radius: 999px;
background: var(--ds-layer-active);
color: var(--ds-fg-secondary);
font-size: 12px;
}
.ds-multiselect input { flex: 1; min-inline-size: 6rem; border: 0; background: none; }
@media (pointer: coarse) {
.ds-token button { inline-size: 24px; block-size: 24px; }
}Component API
MultiSelect
| Prop | Type | Default | Description |
|---|---|---|---|
| options* | Option[] | — | The full set. Filtering is applied to this, not to the selection. |
| values* | string[] | — | Selected values, in selection order. Order matters — reordering on every render makes tokens jump. |
| onChange* | (v: string[]) => void | — | Fires on toggle, on remove, and on select-all or clear. |
| maxVisible | number | 3 | Tokens shown before the overflow counter takes over. |
| size | 'sm' | 'md' | 'lg' | 'md' | Matches the shared control scale. |
| creatable | boolean | false | Allows values not in the list. Adds a "Create «query»" row at the end of the panel. |
| max | number | — | A selection ceiling. Disable unselected options at the limit rather than silently ignoring clicks. |
Professional tips
- Keep the selection in the order the user chose, not sorted. Re-sorting on every pick makes the tokens jump and destroys the sense that the field is theirs.
- Show selected options at the top of the panel when the list is long, so removing does not mean hunting through forty rows.
- Offer "Select all" and "Clear" past about ten options. Selecting nine of ten is otherwise nine clicks.
- For a creatable field, validate as tokens commit and mark bad ones in red rather than refusing them. People fix a visible mistake and get stuck on a field that will not accept input.
- Expand the field on focus to show every token, and collapse back to the capped view on blur. It resolves the overflow tension without a permanent tall field.
Performance
- Keep the selection in a Set for membership checks. An includes() per option per render is quadratic and it shows at a few hundred options.
- Virtualise the panel past roughly 200 rows, adding aria-setsize and aria-posinset when you do.
- Do not animate token layout on add or remove. The field reflows, and animating that reflow is both expensive and disorienting.
- Debounce any request the selection triggers by about 300ms — picking four options should be one request, not four.
Common mistakes
- No Backspace-to-remove, which makes the field feel broken to anyone who has used an email client.
- Closing the panel after each selection, so four picks cost four openings.
- Hiding the selection behind "3 selected", removing the only reason to use this control.
- An uncapped field that wraps to four lines and shifts the rest of the form.
- Every remove button named "Remove", leaving assistive tech with nine identical controls.
- Losing focus to <body> after a token is removed.
- aria-checked instead of aria-selected on listbox options.
- Rejecting a pasted comma-separated list, which is how most recipient fields are actually filled.
Real-world recommendations
- Recipient fields are the reference implementation everyone has internalised. Any deviation from Enter, comma, Backspace and paste-splits is felt immediately even when users cannot name it.
- For permissions and roles, showing a description under each option prevents far more support tickets than any amount of documentation elsewhere.
- Selection limits should disable the remaining options at the ceiling, with an explanation. Silently ignoring the eleventh click looks like a broken control.
- On mobile, a full-screen sheet beats a floating panel every time: the keyboard, the token field and the option list cannot all share the lower half of a phone screen.