mwui
GitHub

Multi Select

A multi-select combobox with chips, async search, and grouped options.

["alice","chen"]

Installation

Usage

A combobox that selects many values, shows them as removable chips, and searches either locally or against your server. Built on Base UI's Combobox, so filtering, typeahead, focus management, and the listbox interaction model come from a primitive that already handles them.

import { MultiSelect } from "@/components/ui/multi-select";

const OPTIONS = [
  { value: "design", label: "Design" },
  { value: "engineering", label: "Engineering" },
  { value: "sales", label: "Sales" },
];

export function TeamFilter() {
  const [teams, setTeams] = React.useState<string[]>([]);

  return <MultiSelect options={OPTIONS} value={teams} onValueChange={setTeams} />;
}

The value is a string[] of option values — not option objects — so it drops straight into a query string, a form body, or a database column without mapping.

Async search

Pass onSearchChange and the component switches to server-driven filtering: local filtering is turned off, and the query is debounced before it reaches you.

const [query, setQuery] = React.useState("");
const { data, isLoading } = useSearchContacts(query);

<MultiSelect
  options={data ?? []}
  value={contacts}
  onValueChange={setContacts}
  onSearchChange={setQuery}
  loading={isLoading}
  placeholder="Search contacts…"
/>;

Selected options keep their labels even after options no longer contains them, which is what makes async search usable: search "ali", select Alice, search "bob", and Alice is still a chip reading "Alice" rather than her raw id. Adjust the debounce with searchDelay, which defaults to 250ms.

Groups

Give options a group and they render under headings, in first-seen order:

const OPTIONS = [
  { value: "alice", label: "Alice", group: "Design" },
  { value: "bob", label: "Bob", group: "Engineering" },
];

Props

PropDefaultDescription
optionsAvailable options. { value, label, disabled?, group? }.
valueControlled selection, as option values.
defaultValue[]Uncontrolled initial selection.
onValueChangeCalled with the next selection.
onSearchChangeEnables async mode and receives the debounced query.
searchDelay250Debounce applied before onSearchChange fires.
loadingfalseShows a spinner and the loading message.
maxSelectedCaps the selection; unselected options disable at the cap.
chipsLayout"wrap""wrap" grows the field over multiple lines; "scroll" keeps one row.
placeholder"Select options"Input placeholder, hidden once anything is selected.
emptyMessage"No results found."Shown when nothing matches.
disabledfalseDisables the whole control.
nameSubmits one hidden input per selected value.

Chips layout

By default the field wraps onto a second line as selections accumulate. In a table row or a tight form grid that growth shifts the surrounding layout, so chipsLayout="scroll" keeps the field at a single row and scrolls the chips horizontally instead:

<MultiSelect chipsLayout="scroll" options={ROLES} />

The scrollbar is hidden, and selecting an option scrolls the newest chip into view so it never lands off-screen. The trade-off is that chips past the right edge are not visible at a glance — prefer "wrap" when the full selection needs to be readable, and pair "scroll" with maxSelected when the count should stay small.

Forms

name renders a hidden input per selected value, which is how HTML forms represent a multi-value field:

<MultiSelect name="tags" options={TAGS} defaultValue={["urgent"]} />

On the server, formData.getAll("tags") returns the array.

Accessibility

Base UI supplies the combobox semantics: the input is role="combobox" with aria-expanded and aria-activedescendant, and the popup is a listbox whose options carry aria-selected. Typeahead, highlight, and focus return on close are handled by the primitive.

Each chip's remove button is labelled with the option it removes — "Remove Alice", not "Remove" — so a screen reader user hears which chip they are on without inspecting the surrounding text.