# Multi Select

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

## Installation

```bash
npx shadcn@latest add https://mwui.vercel.app/r/multi-select.json
```

[Registry JSON](https://mwui.vercel.app/r/multi-select.json)

## Preview

```tsx
import * as React from "react";

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

const OPTIONS = [
  { value: "alice", label: "Alice Nguyen", group: "Design" },
  { value: "bruno", label: "Bruno Costa", group: "Design" },
  { value: "chen", label: "Chen Wei", group: "Engineering" },
  { value: "dara", label: "Dara Okafor", group: "Engineering" },
  { value: "elif", label: "Elif Demir", group: "Engineering" },
  { value: "farid", label: "Farid Haddad", group: "Sales" },
  { value: "gita", label: "Gita Rao", group: "Sales" },
];

export function Preview() {
  const [assignees, setAssignees] = React.useState<string[]>(["alice", "chen"]);

  return (
    <div className="flex w-full max-w-xs flex-col gap-3">
      <MultiSelect
        options={OPTIONS}
        value={assignees}
        onValueChange={setAssignees}
        placeholder="Assign people…"
      />

      <p className="font-mono text-xs text-muted-foreground">
        {assignees.length > 0 ? JSON.stringify(assignees) : "[]"}
      </p>
    </div>
  );
}
```


## Source

### ui/multi-select.tsx

```tsx
"use client";

import { Combobox } from "@base-ui/react/combobox";
import { IconCheck, IconChevronDown, IconX } from "@tabler/icons-react";
import * as React from "react";

import { Spinner } from "@/components/ui/spinner";
import { useControllableState } from "@/hooks/use-controllable-state";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { cn } from "@/lib/utils";

type MultiSelectOption = {
  value: string;
  label: string;
  disabled?: boolean;
  group?: string;
};

type MultiSelectOptionGroup = {
  value: string;
  items: MultiSelectOption[];
};

function isOptionGroup(
  item: MultiSelectOption | MultiSelectOptionGroup,
): item is MultiSelectOptionGroup {
  return "items" in item;
}

function groupOptions(
  options: MultiSelectOption[],
): MultiSelectOption[] | MultiSelectOptionGroup[] {
  if (!options.some((option) => option.group)) return options;

  const groups = new Map<string, MultiSelectOption[]>();

  for (const option of options) {
    const key = option.group ?? "";
    const existing = groups.get(key);

    if (existing) {
      existing.push(option);
    } else {
      groups.set(key, [option]);
    }
  }

  return Array.from(groups, ([value, items]) => ({ value, items }));
}

type MultiSelectChipsLayout = "wrap" | "scroll";

type MultiSelectProps = Omit<React.ComponentProps<"div">, "defaultValue" | "onChange" | "value"> & {
  options: MultiSelectOption[];
  value?: string[];
  defaultValue?: string[];
  onValueChange?: (value: string[]) => void;
  onSearchChange?: (query: string) => void;
  searchDelay?: number;
  loading?: boolean;
  placeholder?: string;
  emptyMessage?: string;
  loadingMessage?: string;
  maxSelected?: number;
  chipsLayout?: MultiSelectChipsLayout;
  disabled?: boolean;
  name?: string;
};

function MultiSelect({
  options,
  value,
  defaultValue = [],
  onValueChange,
  onSearchChange,
  searchDelay = 250,
  loading = false,
  placeholder = "Select options",
  emptyMessage = "No results found.",
  loadingMessage = "Searching…",
  maxSelected,
  chipsLayout = "wrap",
  disabled = false,
  name,
  className,
  ...props
}: MultiSelectProps) {
  const [selected, setSelected] = useControllableState<string[]>({
    value,
    defaultValue,
    onChange: onValueChange,
  });

  const [query, setQuery] = React.useState("");
  const debouncedQuery = useDebouncedValue(query, searchDelay);

  const isAsync = onSearchChange !== undefined;
  const onSearchChangeRef = React.useRef(onSearchChange);

  React.useEffect(() => {
    onSearchChangeRef.current = onSearchChange;
  });

  React.useEffect(() => {
    onSearchChangeRef.current?.(debouncedQuery);
  }, [debouncedQuery]);

  const fieldRef = React.useRef<HTMLDivElement>(null);
  const chipsAreaRef = React.useRef<HTMLDivElement>(null);
  const previousCount = React.useRef(selected.length);
  const [labelCache, setLabelCache] = React.useState<Record<string, string>>({});

  React.useEffect(() => {
    const grew = selected.length > previousCount.current;
    previousCount.current = selected.length;

    if (!grew || chipsLayout !== "scroll") return;

    const area = chipsAreaRef.current;
    if (area) area.scrollLeft = area.scrollWidth;
  }, [selected.length, chipsLayout]);

  React.useEffect(() => {
    setLabelCache((previous) => {
      let changed = false;
      const next = { ...previous };

      for (const option of options) {
        if (next[option.value] !== option.label) {
          next[option.value] = option.label;
          changed = true;
        }
      }

      return changed ? next : previous;
    });
  }, [options]);

  const optionByValue = React.useMemo(
    () => new Map(options.map((option) => [option.value, option])),
    [options],
  );

  const selectedOptions = React.useMemo(
    () =>
      selected.map(
        (item) => optionByValue.get(item) ?? { value: item, label: labelCache[item] ?? item },
      ),
    [selected, optionByValue, labelCache],
  );

  const items = React.useMemo(() => groupOptions(options), [options]);
  const isAtLimit = maxSelected !== undefined && selected.length >= maxSelected;

  const handleValueChange = (next: MultiSelectOption[]) => {
    if (maxSelected !== undefined && next.length > maxSelected) return;
    setSelected(next.map((option) => option.value));
  };

  const renderOption = (option: MultiSelectOption) => (
    <Combobox.Item
      key={option.value}
      value={option}
      disabled={option.disabled ?? (isAtLimit && !selected.includes(option.value))}
      className={cn(
        "flex cursor-default items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none select-none",
        "data-highlighted:bg-muted data-highlighted:text-foreground",
        "data-disabled:pointer-events-none data-disabled:opacity-50",
      )}
    >
      <span className="flex size-4 shrink-0 items-center justify-center">
        <Combobox.ItemIndicator>
          <IconCheck className="size-4" />
        </Combobox.ItemIndicator>
      </span>
      <span className="truncate">{option.label}</span>
    </Combobox.Item>
  );

  return (
    <Combobox.Root
      multiple
      items={items}
      value={selectedOptions}
      onValueChange={handleValueChange}
      isItemEqualToValue={(a: MultiSelectOption, b: MultiSelectOption) => a.value === b.value}
      itemToStringLabel={(option: MultiSelectOption) => option.label}
      itemToStringValue={(option: MultiSelectOption) => option.value}
      filter={isAsync ? null : undefined}
      disabled={disabled}
      onInputValueChange={setQuery}
    >
      <div
        data-slot="multi-select"
        data-disabled={disabled || undefined}
        className={cn("w-full", className)}
        {...props}
      >
        <Combobox.Chips
          ref={fieldRef}
          className={cn(
            "flex min-h-8 w-full gap-1 rounded-lg border border-border bg-background px-1.5 py-1 text-sm",
            chipsLayout === "scroll" ? "items-center" : "items-start",
            "focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50",
            "data-disabled:pointer-events-none data-disabled:opacity-50",
          )}
        >
          <div
            ref={chipsAreaRef}
            className={cn(
              "flex min-w-0 flex-1 items-center gap-1",
              chipsLayout === "scroll"
                ? "overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
                : "flex-wrap",
            )}
          >
            {selectedOptions.map((option) => (
              <Combobox.Chip
                key={option.value}
                className={cn(
                  "flex min-w-0 items-center gap-1 rounded-md bg-muted py-0.5 pr-0.5 pl-1.5 text-xs text-foreground",
                  chipsLayout === "scroll" && "shrink-0",
                )}
              >
                <span className="max-w-40 truncate">{option.label}</span>
                <Combobox.ChipRemove
                  aria-label={`Remove ${option.label}`}
                  className="flex size-4 items-center justify-center rounded-sm text-muted-foreground outline-none hover:bg-background hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50"
                >
                  <IconX className="size-3" />
                </Combobox.ChipRemove>
              </Combobox.Chip>
            ))}

            <Combobox.Input
              placeholder={selectedOptions.length > 0 ? undefined : placeholder}
              className="h-6 min-w-16 flex-1 bg-transparent px-1 outline-none placeholder:text-muted-foreground"
            />
          </div>

          <div className="flex h-6 shrink-0 items-center gap-0.5 pr-0.5">
            {loading ? <Spinner className="size-3.5 text-muted-foreground" /> : null}
            {selectedOptions.length > 0 ? (
              <Combobox.Clear
                aria-label="Clear selection"
                className="flex size-5 items-center justify-center rounded-sm text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50"
              >
                <IconX className="size-3.5" />
              </Combobox.Clear>
            ) : null}
            <Combobox.Trigger
              aria-label="Open options"
              className="flex size-5 items-center justify-center rounded-sm text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50"
            >
              <Combobox.Icon>
                <IconChevronDown className="size-3.5" />
              </Combobox.Icon>
            </Combobox.Trigger>
          </div>
        </Combobox.Chips>

        {name
          ? selected.map((item) => (
              <input key={item} type="hidden" name={name} value={item} readOnly />
            ))
          : null}
      </div>

      <Combobox.Portal>
        <Combobox.Positioner anchor={fieldRef} sideOffset={4} className="z-50 outline-none">
          <Combobox.Popup
            className={cn(
              "max-h-64 w-(--anchor-width) min-w-56 overflow-y-auto overscroll-contain rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-md",
              "origin-(--transform-origin) transition-[transform,opacity] data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0",
            )}
          >
            <Combobox.Status className="px-2 py-1.5 text-sm text-muted-foreground empty:hidden">
              {loading ? loadingMessage : null}
            </Combobox.Status>

            {loading ? null : (
              <Combobox.Empty className="px-2 py-1.5 text-sm text-muted-foreground empty:p-0">
                {emptyMessage}
              </Combobox.Empty>
            )}

            <Combobox.List>
              {(item: MultiSelectOption | MultiSelectOptionGroup) =>
                isOptionGroup(item) ? (
                  <Combobox.Group key={item.value} items={item.items} className="pb-1">
                    <Combobox.GroupLabel className="px-2 py-1 text-xs font-medium text-muted-foreground">
                      {item.value}
                    </Combobox.GroupLabel>
                    <Combobox.Collection>{renderOption}</Combobox.Collection>
                  </Combobox.Group>
                ) : (
                  renderOption(item)
                )
              }
            </Combobox.List>
          </Combobox.Popup>
        </Combobox.Positioner>
      </Combobox.Portal>
    </Combobox.Root>
  );
}

export {
  MultiSelect,
  type MultiSelectChipsLayout,
  type MultiSelectOption,
  type MultiSelectOptionGroup,
  type MultiSelectProps,
};
```



## 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.

```tsx
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.

```tsx
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:

```tsx
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:

```tsx
<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:

```tsx
<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.

