MultiSelect
A multi-value / tag input — an editable field holding several values as removable chips, with a searchable stay-open listbox and opt-in free-text tags.
@garn/ui/multi-selectOn this page
Default
Controlled multi-select over a list of fruits.
With Label
A <Label htmlFor> wired to the field via id.
Options
The data-driven `options` prop with leading glyphs.
Grouped
Options grouped into labelled sections.
Tones
Per-option `tone` — chips tint by status/priority.
Tags
Free-text tags mode — allowCustomValue with a Create row.
Emails
Validated recipients — validate marks bad tokens as danger chips; paste tokenizes.
Max
A `max` cap that blocks further picks + announces.
Overflow
`visibleLimit` collapses chips into a '+N' reveal.
Sizes
xs · sm · md field heights.
States
Disabled · invalid · loading.
Hide Selected
hideSelectedOptions drops chosen rows from the list.
Installation
garn is copy-in — the garn CLI writes this component's source into your project, so you own and can edit it. It resolves any base files and installs the npm dependencies below for you.
npx garn-ui add multi-select- Registry
multi-select- Deps
class-variance-authoritylucide-react- Registry deps
utilscmdkfield-surfaceuse-controllable-stateuse-merged-refuse-announcepopoverspinnertag
Import
import { MultiSelect.Item, MultiSelect.Group, MultiSelect.Separator, MultiSelect.Empty } from "@garn/ui/multi-select";Anatomy
The parts this component is built from — compose them to assemble it.
MultiSelect.ItemRequired[data-slot="multi-select-item"]A selectable option row — leading + label (+ description) + a check when chosen. Requires a stable value; optional tone tints the chip when selected.
MultiSelect.Group[data-slot="multi-select-group"]A labelled section of options (cmdk CommandGroup with a heading).
MultiSelect.Separator[data-slot="multi-select-separator"]A divider between groups.
MultiSelect.Empty[data-slot="multi-select-empty"]The no-results state — cmdk auto-renders it when the filtered count is 0 (or pass emptyText with the options prop).
Props
The component's public props and their types.
appearance- Description
- Field-surface fill.
- Type
"ghost" | "outline" | "soft"- Default
"outline"
size- Description
- Field-surface size scale (chip fields cap at `md`).
- Type
"md" | "sm" | "xs"- Default
"md"
"aria-invalid"- Description
- No description.
- Type
"false" | "true" | boolean
"aria-label"- Description
- No description.
- Type
string
"aria-labelledby"- Description
- No description.
- Type
string
allowCustomValue- Description
- Allow committing free text that matches no option (tags mode).
- Type
boolean- Default
false
children- Description
- Compound `MultiSelect.Item` / `MultiSelect.Group` children.
- Type
React.ReactNode
className- Description
- Applied to the field surface.
- Type
string
clearable- Description
- Show a clear-all button when any value is selected.
- Type
boolean- Default
true
clearLabel- Description
- Accessible name for the clear-all button.
- Type
string- Default
"Clear all"
closeOnSelect- Description
- Close the listbox after each pick (multi-select keeps it open by default).
- Type
boolean- Default
false
contentClassName- Description
- Applied to the popup content.
- Type
string
createLabel- Description
- Renders the "Create …" row label from the current search text.
- Type
(inputValue: string) => React.ReactNode
defaultInputValue- Description
- Initial search text (uncontrolled).
- Type
string- Default
""
defaultOpen- Description
- Whether the popover starts open (uncontrolled).
- Type
boolean- Default
false
defaultValue- Description
- Initial selected values (uncontrolled).
- Type
string[]- Default
[]
disabled- Description
- Disable the field and block opening.
- Type
boolean- Default
false
emptyText- Description
- No-results content (used with the `options` prop; ignored if a child Empty is given).
- Type
React.ReactNode- Default
"No results found."
filter- Description
- Match function, or `false` to disable client filtering (feed pre-filtered `options` for server-side search). Defaults to an accent-insensitive fuzzy match.
- Type
MultiSelectFilter | false
hideSelectedOptions- Description
- Drop chosen options from the list (they already ride as chips).
- Type
boolean- Default
false
id- Description
- No description.
- Type
string
inputValue- Description
- In-field search text (controlled) — drive this for server-side search.
- Type
string
loading- Description
- Pending state: a spinner + a loading row + `aria-busy` + an announced "loading".
- Type
boolean- Default
false
loadingText- Description
- Text shown in the loading row and announced while `loading`.
- Type
string- Default
"Loading…"
max- Description
- Cap the number of selected values; further picks are blocked + announced.
- Type
number
name- Description
- Name for the hidden inputs mirroring each value on form submit.
- Type
string
onClear- Description
- Fires after all values are cleared.
- Type
() => void
onCreate- Description
- Called for each free-text value committed; also renders a "Create …" row.
- Type
(value: string) => void
onInputValueChange- Description
- Fires when the search text changes.
- Type
(inputValue: string) => void
onOpenChange- Description
- Fires when the popover opens or closes.
- Type
(open: boolean) => void
onValueChange- Description
- Fires with the full selected array on every add/remove.
- Type
(value: string[]) => void
open- Description
- Popover open state (controlled).
- Type
boolean
options- Description
- Data-driven options — an alternative to compound children (one or the other).
- Type
MultiSelectOption[]
overflowLabel- Description
- Accessible name for the "+N" overflow reveal popover.
- Type
string- Default
"Show all selected"
placeholder- Description
- Field text when nothing is selected.
- Type
string- Default
"Select…"
ref- Description
- The search input's ref.
- Type
React.Ref<HTMLInputElement>
required- Description
- Mark the field required for form validation.
- Type
boolean
searchLabel- Description
- Accessible name for the search input (role=combobox).
- Type
string- Default
"Search"
splitChars- Description
- Characters that commit the current text as a token (on keydown) and split a pasted string into multiple tokens. Tags mode only.
- Type
string[]- Default
[","]
toggleLabel- Description
- Accessible label for the chevron toggle affordance.
- Type
string- Default
"Toggle options"
validate- Description
- Validate a value as it is added: `true` accepts it, `false` rejects it with a generic reason, a string rejects it with that reason. May be async — the chip shows a pending spinner until it resolves, then a danger chip + reason on rejection. Runs on every added value (picked or free-text).
- Type
(value: string) => boolean | string | Promise<boolean | string>
value- Description
- The selected values (controlled).
- Type
string[]
visibleLimit- Description
- Collapse chips past this count into a "+N" token that reveals the rest in a popover (each removable). Keeps the field compact in dense layouts.
- Type
number
Plus 3 inherited native <input> attributes.
MultiSelect.Item
description- Description
- Secondary line under the label.
- Type
React.ReactNode
leading- Description
- Leading glyph; mirrors into the chip when this option is selected.
- Type
React.ReactNode
textValue- Description
- String used for chip display + matching when `children` is a non-string node.
- Type
string
tone- Description
- Chip tone applied when this option is selected.
- Type
"brand" | "danger" | "discovery" | "info" | "neutral" | "success" | "warning"
valueRequired- Description
- Stable value toggled on select (required).
- Type
string
Plus 283 inherited native HTML attributes.
Styling
Target these data-slots and states, and remap these tokens, to restyle without forking the component.
[data-slot="multi-select-field"][data-slot="multi-select"][data-slot="multi-select-input"][data-slot="multi-select-clear"][data-slot="multi-select-toggle"][data-slot="multi-select-overflow"][data-slot="multi-select-overflow-list"][data-slot="multi-select-list"][data-slot="multi-select-loading-row"][data-slot="multi-select-create"][data-slot="multi-select-item-match"]States
| Selector | State | Description |
|---|---|---|
:hover | hover | The field edge / fill grows stronger (shared field surface). |
:has(input:focus-visible) | focus | The input is focused — the field border grows less transparent (foreground/40) and a subtle ring appears. |
[data-state=open] | open | The listbox popover is open. |
[data-invalid] | invalid | aria-invalid is set — the field border + ring turn danger. |
[data-loading] | loading | Async pending — a spinner replaces the chevron, a loading row shows, and the count is announced as loading. |
[data-disabled] | disabled | The field dims and won't open; chips can't be removed. |
[data-slot=multi-select-item][data-checked] | item-checked | A chosen option — shows its trailing check + an sr-only ', selected'. |
[data-token-state=pending] | token-pending | A just-added token awaiting async validation — a spinner leads the chip. |
[data-token-state=invalid] | token-invalid | A token that failed validation — a danger-toned chip with a warning icon carrying the reason. |
| Property | Token | Tier |
|---|---|---|
fieldMinHeight | --garn-control-h-md | semantic · air |
fieldPaddingInline | --garn-control-px-md | semantic · air |
itemMinHeight | --garn-control-h-md | semantic · air |
When to use
Select several values held as removable chips — from a searchable list, or as free-text tags. Use the options prop or compound MultiSelect.Item children; opt into allowCustomValue for tags (paste/comma tokenize, dedupe, validate); cap with max and collapse overflow with visibleLimit.
Reach for something else when
- Choosing ONE value (use Combobox for searchable, Select for short lists, BadgeSelect for a badge picker)
- a fixed handful of always-visible toggles (use Checkbox or a Toggle group)
- display-only chips with no picker/entry (use Tag / TagGroup)
- free-form single-line text (use Input).
Overview
MultiSelect picks SEVERAL values, held as removable chips in an editable field with a searchable, stay-open listbox. It's the composite between single-select Combobox and display-only Tag/TagGroup: the cmdk search input lives in the field beside the chips, so Backspace removes the last token and typing filters in place. Feed it a data-driven options array (each with an optional tone) or compound MultiSelect.Item children. Opt into allowCustomValue for free-text tags — a 'Create …' row, comma/paste tokenizing, case-insensitive dedupe, and an optional validate that marks bad tokens as danger chips. Use max to cap the count and visibleLimit to collapse overflow into a '+N' reveal.
Guidelines
Reach for MultiSelect when the answer is a set. For one value use Combobox (searchable) or Select (short list); for a fixed handful of visible toggles use a checkbox/Toggle group; for display-only chips use Tag/TagGroup. MultiSelect earns its weight when users assemble a list from a long/searchable or free-text source (recipients, labels, skills).
Choose the mode deliberately. Without allowCustomValue it's a pick-from-list MultiSelect; with it, it's a tags input — set onCreate to persist new values and validate when tokens must be well-formed (emails). Drive inputValue + filter={false} for server-side search.
Keep it accessible and compact. Label the field (the placeholder isn't a name); the roving chip strip, multiselectable listbox, and live announcements are the a11y contract. Use visibleLimit (or hideSelectedOptions) to keep dense fields from ballooning.
Best practices
- Label the field (aria-label / aria-labelledby / id + <label>) — the placeholder isn't a name.
- Give every MultiSelect.Item a stable, unique
value; add atoneto tint status/priority chips. - Enable
allowCustomValue+onCreatefor tag/label/recipient inputs where new values are valid. - Add
validatewhen tokens must be well-formed (e.g. emails) — invalid tokens become danger chips with a reason. - For server-side search, set
filter={false}, driveinputValue+loading, and feed pre-filteredoptions. - Use
maxto enforce a limit andvisibleLimit(orhideSelectedOptions) to keep dense fields compact.
- Don't treat the search text as a value —
inputValueis the transient filter;valueis the committed array. - Don't use MultiSelect for a single choice — that's Combobox / Select.
- Don't reach past the chip: it's a composed Tag inside a roving TagGroup (the component wires removal + roving).
- Don't disable the live announcer or the focus ring — they're the a11y contract.
Content guidelines
- Write a placeholder that names the action ('Add recipients…', 'Select skills…').
- Keep option labels short and parallel; group long lists with MultiSelect.Group.
- For tags mode, hint the entry affordance in the placeholder ('Type and press Enter…').
Troubleshooting
The value is a string, or only the last pick sticks.
Cause. MultiSelect's value is an ARRAY (string[]), not a single string like Combobox.
Fix. Bind value: string[] + onValueChange(next: string[]); use defaultValue={[]} uncontrolled.
Typing a new value can't be committed as a tag.
Cause. Free-text is opt-in; without it, only existing options toggle.
Fix. Set allowCustomValue (+ onCreate to persist). A 'Create …' row appears, and comma/paste tokenize.
Pasted or typed tokens aren't validated / bad ones still count.
Cause. No validate provided, so every added value is accepted.
Fix. Pass validate (sync or async): return true to accept, or false/a string to reject — the chip turns into a danger token with the reason.
The search text becomes a chip / the selection resets.
Cause. Conflating value (the committed array) with inputValue (the transient search).
Fix. Keep them separate — only drive inputValue for server-side search (filter={false} + loading).
Server results never show, or show double-filtered.
Cause. The client fuzzy filter is still on top of your pre-filtered results.
Fix. Set filter={false}, drive inputValue + loading, and feed the already-filtered options.
Screen readers announce the field with no name.
Cause. The placeholder is not an accessible name (a dev console warns).
Fix. Pass aria-label / aria-labelledby, or an id paired with <label htmlFor>.
Accessibility
- Role
combobox- ARIA APG
- combobox
- Focus
- Focus stays in the in-field input; the field border darkens (foreground/40) + a subtle ring via :has(input:focus-visible). ArrowLeft moves focus into the roving chip strip and removal returns it to the input.
Accessibility requirements
Give the field an accessible name via aria-label, aria-labelledby, or an id + <label htmlFor>.
when the MultiSelect field has no `aria-label`, `aria-labelledby`, or an `id` paired with a <label htmlFor> (the placeholder doesn't count)
Keyboard
| A–Z | Type in the field to filter (accent-insensitive contains). |
| ArrowDownArrowUp | Move the active option (opens the list if closed; tracked via aria-activedescendant, focus stays in the input). |
| Enter | Toggle the active option, or commit the 'Create …' row (tags mode). The listbox stays open. |
| ,(splitChars) | Commit the current text as a token (tags mode); pasting a delimited string tokenizes all of it. |
| Backspace | On an empty input, remove the last chip. |
| ArrowLeft | At the caret start, move focus into the chip strip (the roving TagGroup). |
| ArrowLeftArrowRightHomeEnd | Rove between chips once focus is in the strip (logical, RTL-aware). |
| DeleteBackspace | Remove the focused chip and move focus to its neighbour (or back to the input if none remain). |
| Escape | Close the listbox; focus stays in the input. |
- The in-field input is the real role=combobox (cmdk) with aria-expanded / aria-controls / aria-activedescendant / aria-autocomplete=list; the listbox is aria-multiselectable=true.
- Chosen options carry aria-selected=true (true membership — the vendored cmdk lets the consumer value through; the active row is conveyed via data-selected), a visible check, and an sr-only ', selected' appended to the accessible name.
- The selected tokens are a labelled role=grid (Tag TagGroup) with one roving tab stop; each chip's remove is a gridcell <button> named 'Remove {label}'.
- A visually-hidden aria-live region announces the result count / no-results / loading, plus token events — 'N added · M duplicate' on paste, 'Already added', and 'Maximum of N reached'.
- The chevron toggle is a labelled button kept out of the tab order (tabIndex=-1) — the input owns keyboard expansion; the listbox stays mounted while closed (hidden) so aria-controls never dangles.
- Invalid tokens are danger-toned with the failure reason on a leading warning icon's title.
- The placeholder is NOT an accessible name — label the field via aria-label, aria-labelledby, or an `id` paired with <label htmlFor>.