# React Multi Select: Search, Chips, Select All and Mobile

> Build a React multi select that holds up with real data: chips that fold into +N, search, select all over filtered rows, a maximum and a phone bottom sheet.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 16, 2026
- Category: [Component guides](https://wingo-ui.com/blog/category/components)
- Reading time: 10 min
- Canonical: https://wingo-ui.com/blog/react-multi-select

## TL;DR

A good React multi select is a field that opens a list of checkbox rows, keeps the list open while you pick, shows the picks as chips that collapse into a +N chip when the line is full, and adds search, select all and a maximum when the list needs them. On a phone it should open a bottom sheet with large rows and a Done button. Use it for several values from a known list; use a tag input when people type their own values, and plain checkboxes when there are fewer than about seven options.

A react multi select starts simple: a dropdown, a checkbox per row, an array in state. Then real data arrives. Someone picks nine categories and the chips push the field onto three lines. The list has 140 options and nobody can find "Pets". Select all grabs rows the search had hidden. On a phone the popover opens under the thumb that tapped it. Below: what a React multi select component has to handle, the props that matter in the [Multi Select](https://wingo-ui.com/components/multi-select) from Wingo UI (the library we build), keyboard and screen reader behavior, the phone layout, and when a [Tag Input](https://wingo-ui.com/components/tag-input), a [Combobox](https://wingo-ui.com/components/combobox) or plain [checkboxes](https://wingo-ui.com/components/checkbox) are the better control.

## What should a React multi select do?

It should do seven things well. We checked ours against this list, and it works as a review checklist for any react multi select dropdown with checkbox and search:

- **Stay open while picking.** Every row toggles and the list stays open, because people usually pick more than one.
- **Show picks as chips that fit.** As many chips as fit on one line, then a "+N" chip, so the field keeps its height.
- **Remove without opening.** Each chip has its own x, and Backspace removes the last one.
- **Search when the list is long.** Short lists need no search box; long ones are unusable without it.
- **Select all and a maximum.** Select all for "every region", a limit for "up to 3 cities", both explained in the UI.
- **Submit like a native field.** The values reach `FormData`, `required` blocks an empty submit, a form reset restores the default.
- **Work on a phone.** Large rows, 16px text so iOS does not zoom, and a layer the thumb can reach.

Install the Multi Select and render it:

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

```tsx
"use client";

import { useState } from "react";
import { Building2, Car, Dumbbell, Smartphone } 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}
      name="categories"
    />
  );
}
```

The option's icon shows in the row and inside its chip, which is how avatars end up next to names in an assignee picker. Options can also be groups: `{ label: "Vehicles", items: [...] }`.

> Live demo (Multi Select): Open the list and tick rows until the chips no longer fit: the extra ones fold into a +N chip. Remove a chip with its x without opening the list. Try it at [Multi Select](https://wingo-ui.com/components/multi-select) and install it with `npx wingo-ui@latest add multi-select`.

## How do I show +N when multi select chips overflow?

Measure the chips: render every chip invisibly in a hidden layer, add their widths one by one, and stop at the last chip that fits while leaving room for the +N chip. The two shortcuts both break. Wrapping chips onto new lines makes the field grow and pushes the form down. A fixed count ("show 2, then +N") wastes space on a wide field and still overflows on a narrow one with long labels.

Measuring is what `maxVisibleChips={0}` (the default) does. A `ResizeObserver` watches the field and the hidden layer, so the count updates when the window resizes or a web font arrives late and widens the labels. At least one chip always shows, truncated if it has to be, so the field never reads as just "+5". The +N chip carries the hidden names in its `title`, so hovering it shows them.

When you want another trade-off, it is one prop:

| Prop | Result |
| --- | --- |
| `maxVisibleChips={0}` (default) | As many chips as fit on one line, measured on resize, then +N |
| `maxVisibleChips={2}` | At most two chips, then +N |
| `wrap` | Chips flow onto more lines and the field grows |
| `display="count"` | "3 selected" instead of chips |
| `display="text"` | The names joined with commas and truncated |

`display="count"` suits filter bars, where the field is narrow and the picks matter less than the fact that a filter is on.

## How do I add search and select all to a multi select?

Add search once the list passes eight options, select all when "every option" is a real choice, and a maximum when the product has a limit. With `search="auto"` (the default) the search row appears above eight options, or as soon as you pass `loadOptions` or `creatable`; `"always"` and `"never"` override it. Typing filters without caring about case or accents ("zurich" finds "Zürich"), matched letters turn bold, and Backspace in the empty search removes the last chip. For lists that live on a server, `loadOptions` receives the query after a 250ms `debounce` and an `AbortSignal`. The previous request is aborted, so an old response never overwrites a newer one:

```tsx
"use client";

import { useState } from "react";
import { MultiSelect, type MultiSelectItems } from "@/components/ui/multi-select";

export function TagPicker() {
  const [tags, setTags] = useState<string[]>([]);
  return (
    <MultiSelect
      label="Tags"
      options={[]}
      loadOptions={async (query, { signal }) => {
        const res = await fetch(`/api/tags?q=${encodeURIComponent(query)}`, { signal });
        return (await res.json()) as MultiSelectItems;
      }}
      minQueryLength={2}
      value={tags}
      onValueChange={setTags}
    />
  );
}
```

The last 20 queries are cached, so typing back to an earlier query shows its results without a request. The component also remembers the label of every option it has seen, so picked values that are not in the current results keep their chips with real names. A search for "vin" does not turn the "negotiable" chip you picked earlier into a raw id.

`selectAll` adds a row on top. It toggles the visible options only: if someone searched for "new" and pressed Select all, they meant the matching rows. `maxSelected={3}` caps the picks, turns the unselected rows disabled at the limit, and the footer reads "2 of 3 selected". When both are on, Select all stops at the limit.

> Live demo (Multi Select): Type in the search row to filter, then use Select all: it picks only the rows the search left on screen. Try it at [Multi Select](https://wingo-ui.com/components/multi-select) and install it with `npx wingo-ui@latest add multi-select`.

## How do I make a multi select accessible?

Make the field a combobox that controls a listbox with `aria-multiselectable`, keep DOM focus out of the list, and announce the count. In the Multi Select, the field is a button with `role="combobox"`, `aria-haspopup="listbox"`, `aria-expanded` and `aria-activedescendant` pointing at the highlighted row, so a screen reader announces the row under the keyboard while focus stays on the field. When the search row is showing, focus moves into it and the search input carries `aria-activedescendant` instead, so arrows still move through the rows while you type. The field is labelled by its label plus the current summary, so it reads as "Followed categories, 3 selected". The footer count sits in a polite live region, so the new count is read after each toggle.

The keyboard model:

- **Closed:** Enter, Space or Arrow Down open the list, Arrow Up opens on the last row, Home and End open at either end, Backspace removes the last chip.
- **Open:** Arrow Up and Down move and wrap, Page Up and Page Down jump, Enter or Space toggle the highlighted row and keep the list open, Cmd or Ctrl+A selects all while the list (not the search) has focus, Escape or Alt+Arrow Up close and return focus to the field, Tab closes and moves on.

Chip x buttons stay out of the tab order on purpose. Tabbing through nine chips to reach the next field costs nine key presses; Backspace on the field removes the last chip in one. The x buttons still work for mouse and touch, with a larger hit area on coarse pointers.

## How does a multi select work on a phone?

Below 768px the Multi Select opens a bottom sheet instead of the popover (`responsive` is on by default). The sheet is titled with the field's label, the rows are 48px tall with the checkbox at the start, and a sticky footer holds "Clear all" and "Done (3)". Picks in the sheet are a draft: Done commits them, while dragging the sheet down, tapping outside or Escape throws them away. That matches what people expect from a sheet, which is a step they can back out of.

The field itself grows from 40px to 44px on touch screens at the default `md` size, and its text is 16px on phones whatever the size, so iOS Safari does not zoom in when it opens. One touch detail is easy to miss: on a touch screen the search row does not take focus when the list opens, because the on-screen keyboard would cover half the rows the person came to tap. A tap on the search row raises it. Set `search="always"` when typing is the main way in, and the search gets focus right away. Our [bottom sheet guide](https://wingo-ui.com/blog/react-bottom-sheet-for-the-web) explains why a sheet beats a popover on small screens.

## How do I use it with react-hook-form and zod?

Wrap it in `Controller` and validate the array. `ref` and `onBlur` land on the field's button, so react-hook-form can focus it when validation fails:

```tsx
"use client";

import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { MultiSelect, type MultiSelectOption } from "@/components/ui/multi-select";
import { Button } from "@/components/ui/button";

const CITIES: MultiSelectOption[] = [
  { value: "new-york", label: "New York" },
  { value: "chicago", label: "Chicago" },
  { value: "seattle", label: "Seattle" },
  { value: "austin", label: "Austin" },
];

const schema = z.object({
  cities: z.array(z.string()).min(1, { error: "Pick at least one city" }).max(3, { error: "Pick 3 cities at most" }),
});

type Preferences = z.infer<typeof schema>;

export function CityForm({ save }: { save: (data: Preferences) => Promise<void> }) {
  const form = useForm<Preferences>({ resolver: zodResolver(schema), defaultValues: { cities: [] } });
  return (
    <form onSubmit={form.handleSubmit(save)} className="flex flex-col gap-4">
      <Controller
        name="cities"
        control={form.control}
        render={({ field, fieldState }) => (
          <MultiSelect
            label="Preferred cities"
            options={CITIES}
            value={field.value}
            onValueChange={field.onChange}
            onBlur={field.onBlur}
            ref={field.ref}
            maxSelected={3}
            error={fieldState.error?.message}
          />
        )}
      />
      <Button type="submit">Save</Button>
    </form>
  );
}
```

Without a form library, pass `name` and read the hidden inputs in a Server Action with `formData.getAll("cities")`. With `required` and `name`, an empty field blocks the submit like a native select and the browser's message points at the field. Validate on the server anyway: hidden inputs are as editable as any other.

## Should I use a multi select, tag input, combobox or checkboxes?

Use a multi select for several values from a known list of about seven or more options; pick the others by where the values come from and how many there are:

| Situation | Use |
| --- | --- |
| Several values from a known list of 7 or more | Multi Select |
| Fewer than about 7 options, all worth seeing at once | Checkbox group (`CheckboxGroup` with `selectAll` and `max`) |
| Values people type themselves (keywords, skills) | Tag Input |
| A list, plus the odd new value | Multi Select with `creatable` and `onCreate` |
| One value from a long list, typed to find | Combobox |

Checkboxes in the page are the most scannable control when the list is short: no click to open, every option visible. `CheckboxGroup` from the [Checkbox](https://wingo-ui.com/components/checkbox) item takes `options`, `selectAll` and `max` as well, and it is free. The Tag Input is for open-ended values; our [React tag input guide](https://wingo-ui.com/blog/react-tag-input) covers paste splitting and duplicates. The Combobox picks a single value by typing, which is the better fit for a city or a country.

## Does shadcn have a multi select, and how do other libraries compare?

There is no shadcn multi select component by that name, but the shadcn Combobox takes `multiple` and renders chips with `ComboboxChips`. The details, as of October 2026, from each project's docs or source:

| | Wingo UI Multi Select | shadcn/ui Combobox | react-select | MUI Autocomplete | react-multi-select-component |
| --- | --- | --- | --- | --- | --- |
| Multiple values | Yes | `multiple` with `ComboboxChips` | `isMulti` (default `false`) | `multiple` | Yes |
| Chip overflow | Measured +N, or a fixed count | Not documented | Not built in | `limitTags`, a fixed count, applied when not focused | Header text through `valueRenderer` |
| Select all | `selectAll`, visible options only | Not documented | No prop | Not built in | `hasSelectAll` (default `true`) |
| Maximum | `maxSelected` | Not documented | No prop | Not built in | No prop |
| List stays open after a pick | Yes (`closeOnSelect` off) | Not stated | `closeMenuOnSelect` defaults to `true` | Opt in with `disableCloseOnSelect` | Yes (`closeOnChangedValue` off) |
| Phone layout | Bottom sheet below 768px | Not documented | Same menu | Same popper | Same dropdown |
| Styling | Tailwind v4, source in your repo | Tailwind, source in your repo | Emotion, styles API | MUI theme | Own CSS |

Sources: [shadcn/ui Combobox](https://ui.shadcn.com/docs/components/combobox), [react-select source](https://github.com/JedWatson/react-select/blob/master/packages/react-select/src/Select.tsx), [MUI Autocomplete](https://mui.com/material-ui/react-autocomplete/), [react-multi-select-component](https://github.com/hc-oss/react-multi-select-component).

One react-select default surprises people: `hideSelectedOptions` defaults to the value of `isMulti`, so picked options leave the menu and there is no checkbox list until you set it to `false`. That suits a typing-first tag field and confuses people scanning a list.

If your app already runs on MUI, the Autocomplete with `multiple`, `limitTags` and checkbox rows is the sensible choice. react-select fits a typing-first multi select when you are fine with Emotion styling, and react-multi-select-component is an MIT package with select all on by default. The shadcn Combobox is a good start when you only need chips. If you want measured overflow, select all over search results, a maximum and a phone sheet already built, that is what our Multi Select covers.

## Where should you start?

Open the [Multi Select docs](https://wingo-ui.com/components/multi-select): the playground and examples for avatars, a maximum, count and text displays, wrapping chips, groups with select all and async options. Multi Select, Tag Input and Combobox are part of Wingo UI Pro, $8 a month, $80 a year or $150 once as of October 2026 ([pricing](https://wingo-ui.com/pricing)). For the rest of the form, read the [React form components guide](https://wingo-ui.com/blog/react-form-components-guide), and find more in the [component guides category](https://wingo-ui.com/blog/category/components).

## Components in this post

- [Multi Select](https://wingo-ui.com/components/multi-select) (Pro): A React multi select: pick several options shown as chips with a +N overflow, with search, select all, a maximum and async options. Install: `npx wingo-ui@latest add multi-select`
- [Combobox](https://wingo-ui.com/components/combobox) (Pro): A React combobox and autocomplete: type to filter a list or search a server, with matches in bold, a create row and a phone search sheet. Install: `npx wingo-ui@latest add combobox`
- [Tag Input](https://wingo-ui.com/components/tag-input) (Pro): A React tag input that turns typed, pasted or suggested text into removable chips, for tags, keywords and email recipients. Install: `npx wingo-ui@latest add tag-input`
- [Checkbox](https://wingo-ui.com/components/checkbox) (Free): A React checkbox that is on, off or mixed, with a check that draws itself, full-row labels, cards, and groups with select all. Install: `npx wingo-ui@latest add checkbox`

## FAQ

### How do I make a multi select dropdown in React?

Keep an array of selected values in state, render a button that opens a listbox with aria-multiselectable, and toggle a value on each row click without closing the list. A React multi select component such as Wingo UI's Multi Select adds chips with +N overflow, search, select all, a maximum, async options and a phone bottom sheet on top of that.

### Does shadcn/ui have a multi select?

There is no component named Multi Select in shadcn/ui as of October 2026, but its Combobox takes a multiple prop and renders the picks as chips. Its docs show no select all row, no +N overflow and no phone drawer, so those need your own code or a community component.

### How do I show +N more when multi select chips overflow?

Measure the chips in a hidden layer, add them one by one until the next chip plus the +N chip would pass the field's width, and render only those, then a chip with the remaining count. Re-measure with a ResizeObserver, because the field width and font loading both change the result.

### Should select all pick every option or only the filtered ones?

Only the options the person can see. If they searched for "new" and press Select all, they expect the matching rows, not the 200 hidden ones. Wingo UI's Multi Select toggles the visible options and stops at maxSelected.

### How do I submit a multi select with a form?

Render one hidden input per value with the same name and read them with formData.getAll(name), or wrap the component in react-hook-form's Controller and validate the array with zod. Wingo UI's Multi Select renders the hidden inputs when you pass name.

---

Source: https://wingo-ui.com/blog/react-multi-select
