Skip to content
ShineUI
Install

MultiSelect

Many picks, shown as chips.

<sui-multi-select>SelectionStable

About

Multi-choice dropdown: picks show as removable chips in the trigger, and the panel lists one checkbox row per option under a select-all row. Form-associated: posts one entry per picked value under its name. For a list that stays open use sui-listbox; for a few options that fit on the page use sui-checkbox-group.

Usage

<sui-multi-select label="Statuses" name="statuses" placeholder="All statuses"></sui-multi-select> <script type="module"> import { defineCustomElements } from "@shineui/core/loader"; import "@shineui/core/shineui.css"; defineCustomElements(); const el = document.querySelector("sui-multi-select"); el.options = ["Open", "Overdue", "Paid", "Draft", "Scheduled"]; /* SuiMultiSelectEntry[] */ el.value = ["Open", "Overdue"]; /* SuiKey[] */ el.addEventListener("suiChange", (e) => console.log(e.detail)); </script>
import { SuiMultiSelect } from "@shineui/react"; import "@shineui/core/shineui.css"; export function Example() { const options = ["Open", "Overdue", "Paid", "Draft", "Scheduled"]; /* SuiMultiSelectEntry[] */ const value = ["Open", "Overdue"]; /* SuiKey[] */ return <SuiMultiSelect label="Statuses" name="statuses" placeholder="All statuses" options={options} value={value} onSuiChange={(e) => console.log(e.detail)} />; }
<script setup> import { SuiMultiSelect } from "@shineui/vue"; import "@shineui/core/shineui.css"; const options = ["Open", "Overdue", "Paid", "Draft", "Scheduled"]; // SuiMultiSelectEntry[] const value = ["Open", "Overdue"]; // SuiKey[] const onChange = (e) => console.log(e.detail); </script> <template> <SuiMultiSelect label="Statuses" name="statuses" placeholder="All statuses" :options="options" :value="value" @sui-change="onChange" /> </template>
import { Component } from "@angular/core"; import { SuiMultiSelect } from "@shineui/angular"; @Component({ standalone: true, imports: [SuiMultiSelect], template: `<sui-multi-select label="Statuses" name="statuses" placeholder="All statuses" [(open)]="open" [options]="options" [value]="value" (suiChange)="onChange($event)"></sui-multi-select>`, }) export class Example { open = false; options = ["Open", "Overdue", "Paid", "Draft", "Scheduled"]; // SuiMultiSelectEntry[] value = ["Open", "Overdue"]; // SuiKey[] onChange(e: CustomEvent) { console.log(e.detail); } }

Basic

Picks show as removable chips in the trigger, a role="combobox" with aria-haspopup="listbox" that points at the active row through aria-activedescendant. Each chip's X is a button labeled "Remove {label}", so Tab reaches it and Enter or Space removes the pick.

<div style="max-width:380px"> <sui-multi-select id="ms-basic" label="Statuses" name="statuses" placeholder="All statuses"></sui-multi-select> </div>
// setup(root): properties and events setup(root) { const el = root.querySelector('#ms-basic'); el.options = statuses; el.value = ['Open', 'Overdue']; }

Open with select-all and filter

The select-all header is the first row of the listbox, so the trigger can address it. It draws an indeterminate box while only some options are picked, and picking never closes the panel.

<div style="max-width:380px;min-height:340px"> <sui-multi-select id="ms-open" label="Statuses" filter open></sui-multi-select> </div>
// setup(root): properties and events setup(root) { const el = root.querySelector('#ms-open'); el.options = statuses; el.value = ['Open', 'Overdue', 'Paid']; }

Grouped options

A group is { label, items } in the options array and draws an eyebrow header over its rows. Select all still picks every enabled option, whichever group it sits in.

<div style="max-width:380px;min-height:360px"> <sui-multi-select id="ms-groups" label="Statuses" open></sui-multi-select> </div>
// setup(root): properties and events setup(root) { const el = root.querySelector('#ms-groups'); el.options = groupedStatuses; el.value = ['Overdue']; }

Icons and avatars

An option's icon sits between the box and the label, and the chip carries it once picked. render draws the row yourself; a chip stays a chip with the icon and the label, and the label still names the row and drives the filter.

<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;align-items:start;min-height:300px"> <sui-multi-select id="ms-icons" label="Delivery channels"></sui-multi-select> <sui-multi-select id="ms-avatars" label="Reviewers" filter open></sui-multi-select> </div>
// setup(root): properties and events setup(root) { const icons = root.querySelector('#ms-icons'); icons.options = channels; icons.value = ['email', 'portal']; const avatars = root.querySelector('#ms-avatars'); avatars.options = people.map(reviewer(root.ownerDocument)); avatars.value = ['dana-whitfield']; }

Option descriptions

description is a second muted line under the label in the row, for what the option means or costs. It joins the row's accessible description; the filter goes by the label, and the chip shows the label alone.

<div style="max-width:380px;min-height:340px"> <sui-multi-select id="ms-desc" label="Delivery channels" open></sui-multi-select> </div>
// setup(root): properties and events setup(root) { const el = root.querySelector('#ms-desc'); el.options = describedChannels; el.value = ['email']; }

As a property

variant="quiet" draws the chips as they are with no shell around them at rest: the fill comes on hover, the focus ring on focus and the shell only while the panel is open. The placeholder takes the surrounding font. The label is not drawn but still names the control, so the host prints the property name.

Labels Channels
<div style="display:grid;grid-template-columns:76px minmax(0,1fr);gap:12px 10px;align-items:center;max-width:320px;font:550 13.5px/1.5 var(--font-sans);color:var(--ink)"> <span style="font:460 12.5px/1.3 var(--font-sans);color:var(--ink-muted)">Labels</span><sui-multi-select id="ms-prop-labels" variant="quiet" label="Labels" size="sm" max-chips="2" placeholder="No labels"></sui-multi-select> <span style="font:460 12.5px/1.3 var(--font-sans);color:var(--ink-muted)">Channels</span><sui-multi-select id="ms-prop-channels" variant="quiet" label="Channels" size="sm" placeholder="None"></sui-multi-select> </div>
// setup(root): properties and events setup(root) { const el = root.querySelector('#ms-prop-labels'); el.options = labels; el.value = ['backend', 'infra', 'docs']; root.querySelector('#ms-prop-channels').options = channels; }

Chip overflow

maxChips caps the chips drawn in the trigger. The rest collapse into a plain "+N more" count.

<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;align-items:start"> <sui-multi-select id="ms-chips-3" label="Export columns"></sui-multi-select> <sui-multi-select id="ms-chips-1" label="Export columns" max-chips="1"></sui-multi-select> </div>
// setup(root): properties and events setup(root) { ['#ms-chips-3', '#ms-chips-1'].forEach((id) => { const el = root.querySelector(id); el.options = columns; el.value = ['number', 'customer', 'due', 'balance']; }); }

States

required means at least one pick. An error replaces the help line, paints the trigger in the danger tone and sets aria-invalid, and a disabled control posts nothing and never holds the form.

<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;align-items:start"> <sui-multi-select id="ms-help" label="Statuses" help="Leave empty to include every status."></sui-multi-select> <sui-multi-select id="ms-req" label="Export columns" required placeholder="Pick at least one"></sui-multi-select> <sui-multi-select id="ms-err" label="Export columns" required error="Pick at least one column."></sui-multi-select> <sui-multi-select id="ms-off" label="Statuses" disabled help="Locked by the saved report."></sui-multi-select> </div>
// setup(root): properties and events setup(root) { root.querySelector('#ms-help').options = statuses; root.querySelector('#ms-req').options = columns; root.querySelector('#ms-err').options = columns; const off = root.querySelector('#ms-off'); off.options = statuses; off.value = ['Open', 'Overdue']; }

Loading and clearable

While loading is set a spinner stands in for the chevron and the panel shows the loading row instead of options. clearable draws an X that clears every pick at once and reports an empty array.

<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;align-items:start"> <sui-multi-select id="ms-loading" label="Statuses" placeholder="Fetching statuses" loading></sui-multi-select> <sui-multi-select id="ms-clear" label="Statuses" clearable></sui-multi-select> </div>
// setup(root): properties and events setup(root) { const clear = root.querySelector('#ms-clear'); clear.options = statuses; clear.value = ['Open', 'Overdue', 'Paid']; }

Sizes

size is the same sm, md, lg vocabulary and the same 28, 34, 42 floors as the rest of the control family. The trigger grows past its floor when the chips wrap.

<div style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;align-items:end"> <sui-multi-select id="ms-sm" label="Small" size="sm"></sui-multi-select> <sui-multi-select id="ms-md" label="Medium"></sui-multi-select> <sui-multi-select id="ms-lg" label="Large" size="lg"></sui-multi-select> </div>
// setup(root): properties and events setup(root) { ['#ms-sm', '#ms-md', '#ms-lg'].forEach((id) => { const el = root.querySelector(id); el.options = statuses; el.value = ['Open']; }); }

A long list

From virtualScrollThreshold options, 200 by default, the panel draws only the rows near the view and stands the rest in a blank the height of the list. Select all still ticks every option, and the arrows, Home, End, typeahead and the position a screen reader reads count over the whole list.

Open the panel, then press End.
<div style="display:flex;flex-wrap:wrap;gap:14px;align-items:flex-start;min-height:340px"> <div style="max-width:320px;flex:1 1 260px"><sui-multi-select id="ms-long" label="Accounts" filter clearable></sui-multi-select></div> <span id="ms-long-log" style="font:460 13px/1.6 var(--font-sans);color:var(--ink-muted)">Open the panel, then press End.</span> </div>
// setup(root): properties and events setup(root) { const el = root.querySelector('#ms-long'); const log = root.querySelector('#ms-long-log'); el.options = longLedger(5000); const count = () => { // The open panel renders inside the element. const rows = el.querySelectorAll('.opt').length; log.textContent = `5,000 options, ${rows} rows in the page.`; }; el.addEventListener('suiOpen', () => requestAnimationFrame(count)); el.addEventListener('suiChange', (e) => { log.textContent = `${e.detail.length} picked.`; }); }

Read-only

readonly keeps the chips without their remove control and the trigger in the tab order, opens nothing, and the picks still post.

<div style="display:grid;gap:14px;max-width:320px"> <sui-multi-select id="ro-sui-multi-select" label="Status" name="sui-multi-select" readonly required></sui-multi-select> </div>
// setup(root): properties and events setup(root) { const el = root.querySelector('#ro-sui-multi-select'); el.options = statuses; el.value = ['Open', 'Paid']; }

Empty and no matches

emptyMessage is the row for a list that has nothing in it and emptyFilterMessage the row for a search that found nothing; they are two states and two sentences, so "Nothing here yet." is never shown over a long list the reader has just narrowed to none.

<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;align-items:start"> <sui-multi-select id="ms-empty" label="Status" empty-message="No statuses on this board yet." open></sui-multi-select> <sui-multi-select id="ms-nomatch" label="Status" filter empty-filter-message="No status matches that." open></sui-multi-select> </div>
// setup(root): properties and events setup(root) { root.querySelector('#ms-empty').options = []; const el = root.querySelector('#ms-nomatch'); el.options = statuses; requestAnimationFrame(() => { const box = el.querySelector('.sui-popup-search-box input'); if (box) { box.value = 'zzz'; box.dispatchEvent(new Event('input')); } }); }

In a form

Each picked value posts as its own entry under the host name, the way a native select multiple does.

Submit to see what posts.
<form id="ms-form" style="display:grid;gap:14px;max-width:380px"> <sui-multi-select id="ms-form-statuses" label="Statuses" name="status" required></sui-multi-select> <sui-button type="submit" variant="brand" label="Run report"></sui-button> <output id="ms-out" style="font:460 13px/1.6 var(--font-sans);color:var(--ink-muted)">Submit to see what posts.</output> </form>
// setup(root): properties and events setup(root) { const form = root.querySelector('#ms-form'); const out = root.querySelector('#ms-out'); const el = root.querySelector('#ms-form-statuses'); el.options = statuses; el.value = ['Open', 'Overdue']; form.addEventListener('submit', (e) => { e.preventDefault(); const entries = [...new FormData(form)].map(([k, v]) => `${k}=${v}`); out.textContent = entries.length ? entries.join(' ') : 'Nothing posted.'; }); }

Events

suiChange carries the full array of picked values. suiOpen fires after the element opens itself, and the cancelable suiClose fires before it closes with a reason of escape, outside or trigger.

Idle.
<div style="display:flex;flex-wrap:wrap;gap:14px;align-items:center"> <div style="max-width:320px;flex:1 1 260px"><sui-multi-select id="ms-ev" label="Statuses"></sui-multi-select></div> <span id="ms-ev-log" style="font:460 13px/1.6 var(--font-sans);color:var(--ink-muted)">Idle.</span> </div>
// setup(root): properties and events setup(root) { const el = root.querySelector('#ms-ev'); const log = root.querySelector('#ms-ev-log'); el.options = statuses; el.addEventListener('suiOpen', () => { log.textContent = 'Panel open.'; }); el.addEventListener('suiClose', (e) => { log.textContent = `Panel closing (${e.detail}).`; }); el.addEventListener('suiChange', (e) => { log.textContent = e.detail.length ? `Picked ${e.detail.join(', ')}.` : 'Nothing picked.'; }); }

Keyboard

KeyAction
ArrowDown ArrowUpOpens the panel. Once open, moves the active row through the select-all row and the options, skipping disabled ones and wrapping at either end.
EnterOn the trigger, opens the panel or toggles the active row. In the search box, toggles the active row.
SpaceOn the trigger, opens the panel or toggles the active row. In the search box it types a space.
Home EndMoves to the select-all row, or to the last option. In the search box the caret keeps them.
EscapeCloses the panel and returns focus to the trigger.
TabCloses the panel, then reaches each chip's remove control, labeled "Remove {label}", before moving on.
Delete BackspaceOn the trigger, with clearable set, clears every pick.
Printable charactersOn the trigger, jumps the active row to the next label starting with the typed text. In the search box they filter the rows.

API

<sui-multi-select>

Properties

PropertyAttributeTypeDefaultDescription
clearableclearablebooleanfalseX control that clears every pick at once. Delete or Backspace on the trigger does the same.
disableddisabledbooleanfalseDisables the trigger. A disabled control posts nothing and never holds the form.
emptyFilterMessageempty-filter-messagestringstring | undefinedRow shown when the filter matched nothing. An empty list and a search that found nothing are different states and one sentence cannot be right about both: "Nothing here yet." is wrong over a list of two hundred the reader has just narrowed to none.
emptyMessageempty-messagestringstring | undefinedRow shown when the option list is empty.
errorerrorstringstring | undefinedError message; paints the trigger in the danger tone and sets aria-invalid.
filterfilterbooleanfalseSearch box inside the panel. It takes focus when the panel opens.
helphelpstringstring | undefinedHelp text under the control. Replaced by error when set.
invalidinvalidbooleanfalseDanger border without a message, for a form that draws its summary elsewhere.
labellabelstringstring | undefinedLabel above the control.
loadingloadingbooleanfalseShows the options are still on their way: a spinner stands in for the chevron and the panel shows the loading row instead of options. Set it while fetching, clear it when options land.
loadingMessageloading-messagestringstring | undefinedText of the loading row.
maxChipsmax-chipsnumber3Chips drawn in the trigger before the rest collapse into a "+N more" count.
namenamestringstring | undefinedForm control name; each picked value posts under it. Reflected: the form entry is built from the content attribute.
openopenbooleanfalseOpens the panel. Mutable and the single source of truth: suiOpen announces an open the element performed, and the cancelable suiClose asks before it closes itself on outside click, Escape and Tab. Picking never closes the panel.
optionsproperty onlySuiMultiSelectEntry[][]Options: strings or numbers, { label, value, disabled, icon, description, render } objects, or { label, items } groups whose items are either. A group draws a header over its rows. icon is a Font Awesome class drawn before the label in the row and on the chip; description is a muted second line under the label in the row only; render draws the row yourself, for an avatar say, and is called with 'option'. Property, not attribute.
placeholderplaceholderstringstring | undefinedText shown while nothing is picked.
readOnlyreadonlybooleanfalseDisplay only: the chips can be read but not removed, the trigger keeps its name and its tab stop and opens nothing, and the picks still post. Neither a read-only nor a disabled control holds the form on valueMissing.
requiredrequiredbooleanfalseAt least one pick is required for the surrounding form to be valid.
sizesize"lg" | "md" | "sm"'md'Trigger height: sm 28, md 34 (default), lg 42, from the --control-* tokens. The trigger grows past it when chips wrap. In the quiet variant it is the height of the box that paints on hover and while open.
tonetone"inverse" | undefinedInk for the quiet variant on the brand chrome. inverse takes the inverse inks, the inverse hover wash and the inverse focus colour, as sui-button's tone of the same name does, so the value reads on a tone="brand" toolbar. Opening the field shows its shell in the card's own inks. The default variant ignores it.
valueproperty onlySuiKey[][]Picked option values, each as its option gives it (a number stays a number). Mutable; updated before suiChange fires. Property, not attribute.
variantvariant"default" | "quiet"'default'default draws the field shell. quiet draws the chips as they are, with no border or fill around them at rest, for a record's properties column: the fill comes on hover, the focus ring on focus, and the shell only while the panel is open. The placeholder takes the surrounding font. The label is not drawn but still names the control, so the host prints the property name. Keyboard, events, validation and the form post are the same in both. Anything else falls back to default.
virtualScrollThresholdvirtual-scroll-thresholdnumber200Option count at which the panel starts mounting only the rows near the viewport instead of all of them, so a list of exactly this many is windowed. The select-all row, the keyboard, the ids and the counts a screen reader reads still cover every option. Set it to 0 to keep every row in the DOM whatever the list costs, or to 1 to window a short list under test.

Events

EventDetailReactVueDescription
suiChangeSuiKey[]onSuiChange@sui-changeFires when the picks change; detail is the new value, the full array of picked values as their options give them (empty after a clear).
suiCloseSuiCloseReason"cancel" | "close-button" | "escape" | "outside" | "pick" | "scrim" | "trigger"onSuiClose@sui-closeFires before the element closes the panel itself; detail is 'escape' | 'outside' | 'trigger'. Cancelable: preventDefault() keeps it open. Tab reports 'outside', because focus left the control and SuiCloseReason has no member for that. Picking never closes a multi-select. Does not bubble: a preventDefault() in a dialog's own suiClose listener cannot veto this one.
suiOpenvoidonSuiOpen@sui-openFires after the element opens the panel itself (trigger click, ArrowDown, Enter, Space). Does not bubble, so a dialog around the control never takes it for its own.

Methods

MethodSignatureDescription
setFocus()setFocus() => Promise<void>Moves keyboard focus to the trigger, as el.focus() does.

Types

SuiCloseReason typeWhy a dismissible surface is closing. Detail of the cancelable suiClose. A consumer expresses "a stray backdrop click must not close this" by calling preventDefault() on the reason it does not accept, which is why none of these needs a prop of its own.
No members.
SuiKey typeA record's or an item's key wherever the library identifies one: a row, a tree node, a list item, a selected value. Numbers stay numbers, so a numeric id round-trips through selection, the events and a framework binding without turning into a string.
No members.
SuiMultiSelectEntry typeOne entry of options: a string or a number, an option object, or a group.
No members.
SuiMultiSelectGroup interfaceA group: its header label and the options under it. Groups never nest.
labelstring
itemsSuiMultiSelectOptionInput[]
SuiMultiSelectOption interfaceOne option.
labelstringText of the row: what the filter and the typeahead match, what a screen reader hears, and what the chip says once picked.
valueSuiKeyOne of the values value holds, as given: a number stays a number through value, suiChange and a framework binding. The form posts its text.
disabled?booleanGreys the row out; the arrows and select-all step over it.
icon?stringFont Awesome class drawn before the label, in the row and on the chip.
description?stringA second line under the label in the row, in the muted ink, for what the option means or costs. It joins the row's accessible description; the filter, the typeahead and the chip keep to the label. A row with one is taller, so a list long enough to be windowed should give every option one, or none.
render?(place: SuiSelectRenderPlace) => SuiRenderResultDraws the row yourself: return a string or an element. The same signature as a sui-select option's, so one options array serves both; here it is only ever called with 'option', since the trigger draws chips, and a chip carries the icon and the label. It is called on every draw. The label still names the row for a screen reader and still drives the filter and the typeahead. A render that throws, as one that builds nodes with the page's document does on a server, draws the label instead.
SuiMultiSelectOptionInput typeAn option: a string or a number stands for itself as label and value.
No members.
SuiRenderResult type
No members.
SuiSelectRenderPlace typeWhere an option's render output is drawn: its row in the list, or the trigger once it is the value.
No members.

Theming

Every design token this element can render with, read from its own stylesheets and the ones it imports. Set a token on :root or on any ancestor and every use below follows it. The values shown are the ones in the theme you are viewing. The list is read per stylesheet, so an element that imports a shared drawing module lists that module's tokens too, whether or not it paints with all of them.

See also