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
| Prop | Default | Description |
|---|---|---|
options | — | Available options. { value, label, disabled?, group? }. |
value | — | Controlled selection, as option values. |
defaultValue | [] | Uncontrolled initial selection. |
onValueChange | — | Called with the next selection. |
onSearchChange | — | Enables async mode and receives the debounced query. |
searchDelay | 250 | Debounce applied before onSearchChange fires. |
loading | false | Shows a spinner and the loading message. |
maxSelected | — | Caps 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. |
disabled | false | Disables the whole control. |
name | — | Submits 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.