MultiSelect
A multi-value / tag input — an editable field-surface holding several values as removable chips, with a searchable stay-open listbox, per-option chip tone, opt-in free-text tags (Create row, paste + delimiter tokenize, dedupe), a per-token validation lifecycle, a max cap, and a '+N' overflow reveal.
@garn/ui/multi-selectDefault
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- Source
- multi-select.tsx
- Deps
class-variance-authoritylucide-react- Registry deps
utilscmdkfield-surfaceuse-controllable-stateuse-merged-refuse-announcepopoverspinnertag
Import
import { MultiSelect } from "@garn/ui/multi-select";Props
Generated from the component's TypeScript types.
appearance- Description
- No description.
- Type
"ghost" | "outline" | "soft"- Default
outline
size- Description
- No description.
- 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
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. Default true.
- Type
boolean
clearLabel- Description
- No description.
- Type
string
closeOnSelect- Description
- Keep the listbox open after a pick (the multi-select default).
- Type
boolean
contentClassName- Description
- Applied to the popup content.
- Type
string
createLabel- Description
- No description.
- Type
(inputValue: string) => React.ReactNode
defaultInputValue- Description
- No description.
- Type
string
defaultOpen- Description
- No description.
- Type
boolean
defaultValue- Description
- No description.
- Type
string[]
disabled- Description
- No description.
- Type
boolean
emptyText- Description
- No-results content (used with the `options` prop; ignored if a child Empty is given).
- Type
React.ReactNode
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
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
loadingText- Description
- No description.
- Type
string
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
- No description.
- Type
() => void
onCreate- Description
- Called for each free-text value committed; also renders a "Create …" row.
- Type
(value: string) => void
onInputValueChange- Description
- No description.
- Type
(inputValue: string) => void
onOpenChange- Description
- No description.
- Type
(open: boolean) => void
onValueChange- Description
- No description.
- 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
placeholder- Description
- Field text when nothing is selected.
- Type
string
ref- Description
- The search input's ref.
- Type
React.Ref<HTMLInputElement>
required- Description
- No description.
- Type
boolean
searchLabel- Description
- Accessible name for the search input (role=combobox).
- Type
string
splitChars- Description
- Characters that commit the current text as a token (on keydown) and split a pasted string into multiple tokens. Tags mode only. Default `[","]`.
- Type
string[]
toggleLabel- Description
- Accessible label for the chevron toggle affordance.
- Type
string
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 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"]| Property | Token | Tier |
|---|---|---|
fieldMinHeight | --garn-control-h-md | semantic · air |
fieldPaddingInline | --garn-control-px-md | semantic · air |
itemMinHeight | --garn-control-h-md | semantic · air |
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…').
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.
Enforceable contracts
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>.
- Status
- Stable
- Version
v0.3.0- Introduced
v0.2.0
garn doesn't yet record a per-release version history. This reflects the component's current lifecycle state; full changelog entries will appear once the metadata pipeline emits a lifecycle.history field.