# Multi Select

> A React multi select: pick several options shown as chips with a +N overflow, with search, select all, a maximum and async options.

- Type: React component (Inputs)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.0
- Install: `npx wingo-ui@latest add multi-select`
- Docs: https://wingo-ui.com/components/multi-select

## Interaction

Click or tap the field to open the list under it; every row has a checkbox, and clicking a row toggles it while the list stays open (closeOnSelect closes it after each pick). The chosen options show as chips in the field: as many as fit on one line, measured as the field resizes, then a "+N" chip whose number crossfades; chips slide in and out as you pick. Each chip's x removes it without opening the list, and an x at the end of the field clears everything. The footer under the list shows the count ("3 selected", or "2 of 3 selected" with a maximum) and a Clear all button; at the maximum the unselected rows turn disabled. With selectAll a row on top toggles every visible option. Above 8 options (or with loadOptions, creatable or search="always") a search row sits on top and takes focus: typing filters, case and diacritics never matter and matched letters turn bold; Backspace in the empty search removes the last chip. display="count" shows "3 selected" instead of chips, display="text" the names joined and truncated, and wrap lets chips flow onto more lines while the field grows. Keyboard: Tab reaches the field (chip x buttons stay out of the tab order) and a click on its label focuses it. While closed, Enter, Space and ArrowDown open it, ArrowUp opens on the last option, Home and End open at the ends, and Backspace removes the last chip. While open, ArrowDown and ArrowUp move and wrap, PageUp and PageDown jump, Enter or Space toggle the highlighted row and keep the list open, Cmd/Ctrl+A selects all (when the list, not the search, has focus), Escape or Alt+ArrowUp close and keep focus on the field, Tab closes and moves on. On a phone the field opens a bottom sheet titled with the label, with 48px checkbox rows and a sticky footer: "Done (3)" commits the picks and "Clear all" empties the draft; dragging the sheet down, tapping outside or Escape discards the changes. With name, one hidden input per value submits (FormData.getAll), an empty required field blocks the submit and a form reset restores the default. With reduced motion chips and the list appear without sliding or scaling.

## Installation

```bash
npx wingo-ui@latest add multi-select
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/multi-select` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/multi-select.tsx`

npm dependencies: `npm i @radix-ui/react-popover motion lucide-react`

Wingo UI dependencies (installed automatically):

- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))
- [Listbox](https://wingo-ui.com/components/listbox) ([Markdown](https://wingo-ui.com/components/listbox.md))
- [Field](https://wingo-ui.com/components/field) ([Markdown](https://wingo-ui.com/components/field.md))
- [Drawer](https://wingo-ui.com/components/drawer) ([Markdown](https://wingo-ui.com/components/drawer.md))
- [Badge](https://wingo-ui.com/components/badge) ([Markdown](https://wingo-ui.com/components/badge.md))
- [Button](https://wingo-ui.com/components/button) ([Markdown](https://wingo-ui.com/components/button.md))
- [Spinner](https://wingo-ui.com/components/spinner) ([Markdown](https://wingo-ui.com/components/spinner.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [Tones](https://wingo-ui.com/components/tones) ([Markdown](https://wingo-ui.com/components/tones.md))
- [useMediaQuery](https://wingo-ui.com/components/use-media-query) ([Markdown](https://wingo-ui.com/components/use-media-query.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.md))

## Usage

```tsx
import { useState } from "react"
import { Car, Building2, Smartphone, Dumbbell } from "lucide-react"
import { MultiSelect, type MultiSelectOption } from "@/components/ui/multi-select"

const CATEGORIES: MultiSelectOption[] = [
  { value: "real-estate", label: "Real estate", icon: <Building2 /> },
  { value: "vehicles", label: "Vehicles", icon: <Car /> },
  { value: "electronics", label: "Electronics", icon: <Smartphone /> },
  { value: "sports", label: "Sports", icon: <Dumbbell /> },
]

export function FollowedCategories() {
  const [categories, setCategories] = useState<string[]>(["vehicles"])
  return (
    <MultiSelect
      label="Followed categories"
      placeholder="Pick categories"
      options={CATEGORIES}
      value={categories}
      onValueChange={setCategories}
      maxSelected={3}
      selectAll
      name="categories"
      labels={{
        selected: (n) => `${n} picked`,
        limit: (n, max) => `${n} of ${max} picked`,
      }}
    />
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `options` | `MultiSelectItems` |  | Options and groups: { value, label, description?, icon?, badge?, disabled?, keywords?, textValue? } or { label, items }. The icon also shows inside the chip. |
| `value?` | `string[]` |  | The chosen values, controlled. Values not among the options keep their chips (labels from the cache, else the value). |
| `defaultValue?` | `string[]` |  | The starting values while uncontrolled (empty when omitted). A form reset returns to them. |
| `onValueChange?` | `(value: string[]) => void` |  | Called with the new values after a toggle, a chip removal, select all, clear, or Done in the phone sheet. |
| `label?` | `ReactNode` | `""` | The field label. A click focuses the trigger; plain text also titles the phone sheet and names the trigger with the count. |
| `description?` | `ReactNode` | `""` | Helper text under the field, linked with aria-describedby. |
| `error?` | `ReactNode` | `""` | Replaces the description with an animated error and marks the field invalid. |
| `required?` | `boolean` | `false` | The red mark after the label, aria-required on the trigger; with name an empty field blocks the form submit. |
| `invalid?` | `boolean` | `false` | The invalid look and aria-invalid without a message. |
| `disabled?` | `boolean` | `false` | Dims the field, never opens, chips are not removable and nothing is submitted. |
| `readOnly?` | `boolean` | `false` | The chips stay visible and the field focusable, but it never opens and nothing can be removed or cleared. |
| `placeholder?` | `string` | `"Select options"` | Shown in the field while nothing is chosen. |
| `display?` | `"chips" \| "count" \| "text"` | `"chips"` | How the value shows in the field: chips; "3 selected"; or the names joined and truncated. |
| `maxVisibleChips?` | `number` | `0` | At most this many chips, then +N. 0 shows as many as fit on one line, measured on resize. |
| `wrap?` | `boolean` | `false` | Chips wrap onto more lines and the field grows instead of collapsing into +N. |
| `chipRemovable?` | `boolean` | `true` | Each chip has an x that removes it without opening the list; Backspace on the field removes the last one. |
| `showChipIcons?` | `boolean` | `true` | The option's icon (an avatar, a flag) inside its chip. |
| `maxSelected?` | `number` | `0` | The most options that can be chosen; at the limit the other rows turn disabled. 0 means no limit. |
| `selectAll?` | `boolean` | `false` | A "Select all" row on top that toggles every visible option (respecting maxSelected). |
| `clearable?` | `boolean` | `true` | An x at the end of the field that clears everything while something is chosen. |
| `closeOnSelect?` | `boolean` | `false` | Closes the popover after every pick. The phone sheet always waits for Done. |
| `search?` | `"auto" \| "always" \| "never"` | `"auto"` | The search row: auto shows it above 8 options, with loadOptions or with creatable. |
| `searchPlaceholder?` | `string` | `"Search…"` | The search row's placeholder. |
| `emptyText?` | `string` | `"No results"` | Shown when the search matches nothing (or there are no options). |
| `tone?` | `"neutral" \| "brand" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | The chips, the checkboxes and the sheet's Done button. |
| `color?` | `string` | `""` | Any CSS color for the chips and checkboxes; replaces the tone. Leave empty to use the tone. |
| `loading?` | `boolean` | `false` | Options are on their way: a spinner replaces the chevron and the list shows a loading row. |
| `loadingText?` | `string` | `"Loading…"` | The loading row's text and the spinner's accessible name. |
| `highlightMatches?` | `boolean` | `true` | Bold matched letters while searching. |
| `leftIcon?` | `ReactNode` |  | A leading icon in the field. |
| `size?` | `"sm" \| "md" \| "lg"` | `"md"` | 32 / 40 / 48px (md grows to 44px on touch) with 20 / 24 / 28px chips, 16px text on phones. |
| `variant?` | `"soft" \| "outline" \| "underline" \| "ghost"` | `"soft"` | The field look, the same as every other field. |
| `radius?` | `"none" \| "sm" \| "md" \| "lg" \| "full"` | `"md"` | Corner radius, scaled per size like the button; full also makes the chips pills. Ignored by underline. |
| `name?` | `string` | `""` | Renders one hidden input per value with this name, so FormData.getAll(name), required and form reset work. |
| `id?` | `string` |  | The trigger's id (useId() by default); the label points at it. |
| `fieldProps?` | `Partial<FieldProps>` |  | Anything else for the Field wrapper: optional, labelAction, orientation, labelWidth, hideLabel. |
| `defaultOpen?` | `boolean` | `false` | Starts with the list (or the sheet) open. Pass open and onOpenChange to control it. |
| `open?` | `boolean` |  | Whether the list is open, controlled. It never opens while disabled or readOnly. |
| `onOpenChange?` | `(open: boolean) => void` |  | Called when the list opens or closes. |
| `responsive?` | `boolean` | `true` | A bottom sheet with 48px checkbox rows and a Done button below 768px instead of the popover. |
| `sheetTitle?` | `string` | `""` | The phone sheet's title. Empty uses the label's text, else aria-label, else the placeholder. |
| `side?` | `"top" \| "right" \| "bottom" \| "left"` | `"bottom"` | Where the list opens; it flips when there is no room. |
| `align?` | `"start" \| "center" \| "end"` | `"start"` | How the list lines up with the field. |
| `maxHeight?` | `number` | `360` | The list's maximum height in px (search and footer included), capped by the room on screen. |
| `virtualThreshold?` | `number` | `100` | Above this many visible options only the rows in view render. |
| `loadOptions?` | `(query: string, context: { signal: AbortSignal }) => Promise<MultiSelectItems>` |  | Async options for the debounced query; the previous request is aborted and results are cached per query. |
| `debounce?` | `number` | `250` | ms to wait after typing before loadOptions runs. |
| `minQueryLength?` | `number` | `0` | loadOptions waits for this many characters. |
| `creatable?` | `boolean` | `false` | A create row when the query matches nothing exactly; pair it with onCreate. |
| `onCreate?` | `(query: string) => (string \| void \| Promise<string \| void>)` |  | Creates an option from the query; a returned value is selected. |
| `createLabel?` | `(query: string) => ReactNode` |  | The create row's text (default: Create "query"). |
| `filter?` | `boolean, or (option: MultiSelectOption, query: string) => score (number, null hides)` |  | Default true: true filters with text-match; false means the options are already filtered; a function scores (null hides). |
| `fuzzy?` | `boolean` | `false` | Fuzzy matching for the search. |
| `renderOption?` | `(option, state: { selected, highlighted, disabled, ranges }) => ReactNode` |  | Replaces a row's content; the row element, its checkbox, aria and classes stay. |
| `container?` | `HTMLElement \| null` |  | Where the list or the sheet is portaled (document.body by default). |
| `labels?` | `Partial<MultiSelectLabels>` |  | Every string: { selectAll, clearAll, done, selected(n), limit(n, max), more(n), remove(label), results(n), clearSearch, retry }. Defaults: "Select all", "Clear all", "Done", "3 selected", "2 of 3 selected", "+3", "Remove Auto", the announced result count, the search clear button and the retry button. |
| `classNames?` | `Partial<Record<"field" \| "label" \| "description" \| "error" \| "counter" \| "box" \| "trigger" \| "icon" \| "chips" \| "chip" \| "overflow" \| "placeholder" \| "summary" \| "clear" \| "chevron" \| "content" \| "search" \| "list" \| "item" \| "group" \| "empty" \| "footer", string>>` |  | Extra classes for the inner parts. box is the field box, trigger the button covering it, content the popover (or the sheet). |
| `className?` | `string` |  | Merged onto the Field root when there is a label, description or error, otherwise onto the box. |
| `ref?` | `RefObject<HTMLButtonElement \| null> or (node) => void` |  | The trigger button; other props (onBlur, aria-*, data-*, form) go there too. |

## Examples

- **People with avatars**: Avatars as option icons, shown in the rows and inside the chips; search by name or role.
- **A maximum**: maxSelected={3}: at the limit the other rows turn disabled and the footer reads “3 of 3 selected”.
- **Count, text and chips**: The same value shown as a count, as a truncated list of names and as chips with a measured +N.
- **Wrapping chips**: wrap lets chips flow onto more lines and the box grows; each chip's x removes it without opening the list.
- **Groups and select all**: Group labels, a disabled row and a “Select all” row that toggles every visible option, in the brand tone.
- **Async options**: loadOptions against a fake API; results stay on screen while the next query loads and selected labels are kept.
- **Sizes, variants and colors**: 32, 40 and 48px, the four field looks, a tone and a custom color for chips and checkboxes.

## See also

- [Select](https://wingo-ui.com/components/select) ([Markdown](https://wingo-ui.com/components/select.md))
- [Listbox](https://wingo-ui.com/components/listbox) ([Markdown](https://wingo-ui.com/components/listbox.md))
- [Combobox](https://wingo-ui.com/components/combobox) ([Markdown](https://wingo-ui.com/components/combobox.md))
- [Badge](https://wingo-ui.com/components/badge) ([Markdown](https://wingo-ui.com/components/badge.md))
- [Field](https://wingo-ui.com/components/field) ([Markdown](https://wingo-ui.com/components/field.md))
- [Drawer](https://wingo-ui.com/components/drawer) ([Markdown](https://wingo-ui.com/components/drawer.md))

---

Canonical: https://wingo-ui.com/components/multi-select
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
