Skip to content
garn

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.

Stablev0.4.2added in v0.2.0@garn/ui/multi-select
On 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.

terminal
npx garn-ui add multi-select
First time? Set up garn in your project
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

SelectorState
:hoverhover
:has(input:focus-visible)focus
[data-state=open]open
[data-invalid]invalid
[data-loading]loading
[data-disabled]disabled
[data-slot=multi-select-item][data-checked]item-checked
[data-token-state=pending]token-pending
[data-token-state=invalid]token-invalid
PropertyTokenTier
fieldMinHeight--garn-control-h-mdsemantic · air
fieldPaddingInline--garn-control-px-mdsemantic · air
itemMinHeight--garn-control-h-mdsemantic · 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

Do
  • Label the field (aria-label / aria-labelledby / id + <label>) — the placeholder isn't a name.
  • Give every MultiSelect.Item a stable, unique value; add a tone to tint status/priority chips.
  • Enable allowCustomValue + onCreate for tag/label/recipient inputs where new values are valid.
  • Add validate when tokens must be well-formed (e.g. emails) — invalid tokens become danger chips with a reason.
  • For server-side search, set filter={false}, drive inputValue + loading, and feed pre-filtered options.
  • Use max to enforce a limit and visibleLimit (or hideSelectedOptions) to keep dense fields compact.
Don't
  • Don't treat the search text as a value — inputValue is the transient filter; value is 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

warn

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–ZType in the field to filter (accent-insensitive contains).
ArrowDownArrowUpMove the active option (opens the list if closed; tracked via aria-activedescendant, focus stays in the input).
EnterToggle 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.
BackspaceOn an empty input, remove the last chip.
ArrowLeftAt the caret start, move focus into the chip strip (the roving TagGroup).
ArrowLeftArrowRightHomeEndRove between chips once focus is in the strip (logical, RTL-aware).
DeleteBackspaceRemove the focused chip and move focus to its neighbour (or back to the input if none remain).
EscapeClose the listbox; focus stays in the input.
A1.3.1Info and RelationshipsA1.4.1Use of ColorA2.1.1KeyboardAA2.4.7Focus VisibleA4.1.2Name, Role, Value
  • 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>.