Skip to content

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.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground

Applies to every project in this team.

2 of 5 selected

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.

Aada@example.comGgrace@example.com

Enter or comma to add. Backspace on an empty field removes the last one. Paste a list to add several.

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.

5 optionsCheckboxes
Read deployments
Write deployments
Manage members
40 optionsMulti-select

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.

Capped
Uncapped
Read deploymentsWrite deploymentsManage membersBillingSecrets

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.

Read deploymentsWrite deploymentsManage members

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.

Empty
One
Several
Overflowing
Small
Large
Read deployments
Token
ALada@example.com
Token with avatar
+9 more
Overflow counter
Read deployments
Checked option

Anatomy

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.

  1. 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.

  2. Token24px, pill, removable

    A small Chip. Smaller than the free-standing chip so several fit on one line inside a 36px field.

  3. Token gap6px

    Tight enough that the set reads as one value, wide enough that two adjacent remove buttons are not mis-tapped.

  4. 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.

  5. 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.

  6. Panel behaviourStays open on select

    The defining difference from a Select. Closing after each pick makes four selections cost four openings.

  7. CountLive region under the field

    "4 of 5 selected". The only feedback a non-visual user gets that a toggle landed.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed 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

TokenValueUsed for
token gapBetween tokens
field paddingReduced from a text field to make room for tokens

Radius

TokenValueUsed for
full—Token shape
--radius-mdField corners

Shadow

TokenValueUsed for
--shadow-e4—Panel elevation

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightPaddingRadiusLabel gapTypeMin widthWhen to use
Small32px min4px—4px13px—Table filters and dense forms.
Medium36px min6px—6px15px14remThe default.
Large44px min8px—6px16px—Touch layouts and recipient fields.
Token24px0 4px 0 8pxfull—12px—Smaller than a standalone Chip so several fit inside the field.
Field ceiling3 lines of tokens—————Past this, overflow into a counter. Four lines pushes the rest of the form down the page.

Do

if (e.key === 'Backspace' && draft === '')
  removeLast()
Support Backspace on an empty fieldEvery user has learned it from email "To" fields. It costs three lines, and its absence makes the field feel wrong in a way people cannot name.
onSelect → toggle(value) ✗ close()
Keep the panel open while pickingSelecting four permissions should be four clicks, not four clicks plus four reopenings — and the panel moving between clicks causes mis-selections.
a@x.com, b@x.com→a@x.comb@x.com
Split a pasted list into tokensPeople paste ten addresses far more often than they type them. Rejecting a pasted list is the fastest way to make a recipient field feel hostile.
aria-label="Remove Read deployments"
Give every remove button its own name"Remove" repeated nine times is useless to a screen-reader user. "Remove Read deployments" says exactly what disappears.

Don't

3 selected▾
Do not hide the selection behind a count"3 selected" makes the user reopen the panel every time they want to check what they chose. The tokens are the reason to use this control at all.
Read deploymentsWrite deploymentsManage membersBillingSecretsRead deploymentsWrite deploymentsManage membersBillingSecrets
Do not let the field wrap to four linesEvery selection then pushes the rest of the form down the page, and the submit button moves while the user is aiming at it.
Do not use it for five optionsCheckboxes show every option and its state at once. A control that hides five options behind a click is strictly worse than showing them.
Click anywhere to delete
Do not make the whole token the remove targetThe user cannot then click a token to edit or re-open it, and a stray click deletes a selection instead of doing nothing.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.4.11Non-text ContrastAA2.1.1KeyboardA2.5.8Target Size (Minimum)AA4.1.2Name, Role, ValueA4.1.3Status MessagesAA

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 / EnterToggles the highlighted option. The panel does not close.
BackspaceOn 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.
DeleteOn a focused token, removes it and moves focus to the next one.
EscCloses the panel without changing the selection.
TabLeaves 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.
AttributeApplied toNotes
role="combobox"The inputWith aria-multiselectable on the listbox and aria-expanded on the input.
aria-activedescendantThe inputTracks the highlighted option without moving DOM focus, exactly as in a single Combobox.
aria-selectedEach optionNot aria-checked. In a multi-selectable listbox, selected is the correct state.
aria-labelEach remove buttonMust 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-describedbyThe fieldPoints at the instruction — "Enter or comma to add" — which otherwise exists only visually.

Code

Example usage

tsx
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

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

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

PropTypeDefaultDescription
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.
maxVisiblenumber3Tokens shown before the overflow counter takes over.
size'sm' | 'md' | 'lg''md'Matches the shared control scale.
creatablebooleanfalseAllows values not in the list. Adds a "Create «query»" row at the end of the panel.
maxnumber—A selection ceiling. Disable unselected options at the limit rather than silently ignoring clicks.

Notes

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.