# Shadcn Multi-Select

> Free shadcn/ui multi-select component for React — searchable, creatable tags, async loading, max items, grouped options and React Hook Form. The component shadcn/ui never shipped. Copy or install in one command.

Source: https://designrevision.com/components/multi-select

The **Shadcn Multi-Select** is the component shadcn/ui never shipped — a searchable dropdown for selecting **several** values, shown as removable tokens. Official shadcn/ui has a single-value [Select](/components/select) and a combobox *pattern*, but **no multi-select component**; this one fills that gap, built the shadcn way on the [Popover](/components/select) and Command primitives so it matches your existing UI. The examples below cover tags, async search, max items, grouping, and forms.

## When to use — multi-select vs combobox vs checkbox group

Reach for a multi-select when users choose **several** values from a list long enough to need search — skills, tags, assignees, categories. The selected values appear as removable badges, and typing filters the options. For picking a **single** value from the same searchable pattern, use a combobox. For a **short, always-visible** set of independent options, a [checkbox](/components/checkbox) group reads better than a dropdown.

## Creatable tags

Set `creatable` and the multi-select becomes a **tags input**: when the query matches no existing option, a "Create …" row appears, and selecting it adds the typed value. This is the pattern behind labels, topics, and free-form tagging — no separate component required.

## Async search

For directories or API-backed lists, pass an `onSearch` function that returns a `Promise` of options. The component **debounces** the query, shows a loading state, and remembers the labels of already-selected items so the tokens stay readable even as the option list changes underneath them.

## Max items and states

Cap the selection with `maxSelected` — once the limit is reached, the unselected options disable so users can't exceed it. Individual options can be `disabled` (never selectable) or `fixed` (selected and non-removable, e.g. a baseline permission), and the whole field accepts `disabled`.

## React Hook Form

The value is a plain `string[]`, so it drops into <a href="https://react-hook-form.com" rel="nofollow">React Hook Form</a> cleanly: read the field into `value` and write it back from `onChange`. Validate with a `zod` array schema like `z.array(z.string()).min(1)` and set `aria-invalid` on the field for the error styling. The React Hook Form example is a complete, working field.

## Accessibility

The trigger is a `role="combobox"` button with `aria-expanded`, and the list is the keyboard-navigable Command (arrow keys, type to filter, Enter to toggle, Escape to close). Each token's remove control carries an `aria-label`. Bind the field to a [Label](/components/label) with `htmlFor`/`id`, and link any error message with `aria-describedby`.

## Theming

The multi-select is assembled from the themed Popover, Command, and [Badge](/components/badge) primitives, so it reads your design tokens (`--primary`, `--popover`, `--input`, `--ring`) and follows your theme — including dark mode — with zero overrides.

## Installation

### multi-select

**Install:**

```bash
npx shadcn@latest add @designrevision/multi-select
```

**Dependencies:** lucide-react, popover, command, badge, utils

**Props**

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| options * | MultiSelectOption[] | — | The available options. Each has value, label, and optional disabled, fixed, group, and icon. |
| value * | string[] | — | The selected values (controlled). |
| onChange * | (value: string[]) => void | — | Fires with the new array of selected values. |
| maxSelected | number | — | Caps how many items can be selected. |
| creatable | boolean | false | Allow creating new entries from the search query. |
| onSearch | (query: string) => Promise<MultiSelectOption[]> | — | Async option source — when set, options are fetched per (debounced) query. |

`*` required.

**Usage & accessibility:** A token-input multi-select built on Popover + Command + Badge. Set aria-invalid for the destructive error styling. Also accepts placeholder, disabled, emptyText, and loadingText.

```tsx
// components/ui/multi-select.tsx
"use client"

import * as React from "react"
import { CheckIcon, ChevronsUpDownIcon, XIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import { Badge } from "@/components/ui/badge"
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
} from "@/components/ui/command"
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover"

export type MultiSelectOption = {
  value: string
  label: string
  disabled?: boolean
  /** Cannot be removed once selected. */
  fixed?: boolean
  /** Optional group heading the option is listed under. */
  group?: string
  /** Optional leading content (icon, avatar) shown in the list. */
  icon?: React.ReactNode
}

export interface MultiSelectProps {
  options: MultiSelectOption[]
  value: string[]
  onChange: (value: string[]) => void
  placeholder?: string
  /** Cap the number of selected items. */
  maxSelected?: number
  /** Allow creating new entries from the search query. */
  creatable?: boolean
  disabled?: boolean
  className?: string
  emptyText?: string
  loadingText?: string
  /** Async option source. When provided, options are fetched per query. */
  onSearch?: (query: string) => Promise<MultiSelectOption[]>
  id?: string
  "aria-invalid"?: boolean
  "aria-describedby"?: string
}

export function MultiSelect({
  options: optionsProp,
  value,
  onChange,
  placeholder = "Select…",
  maxSelected,
  creatable = false,
  disabled = false,
  className,
  emptyText = "No results.",
  loadingText = "Loading…",
  onSearch,
  id,
  "aria-invalid": ariaInvalid,
  "aria-describedby": ariaDescribedby,
}: MultiSelectProps) {
  const [open, setOpen] = React.useState(false)
  const [query, setQuery] = React.useState("")
  const [asyncOptions, setAsyncOptions] = React.useState<MultiSelectOption[]>([])
  const [loading, setLoading] = React.useState(false)
  const labels = React.useRef(new Map<string, string>())

  const options = onSearch ? asyncOptions : optionsProp

  // Remember labels so selected tokens stay readable even after the
  // (async) option list changes.
  React.useEffect(() => {
    for (const option of [...optionsProp, ...asyncOptions]) {
      labels.current.set(option.value, option.label)
    }
  }, [optionsProp, asyncOptions])

  // Resolve a value's label synchronously (so tokens render correctly on the
  // first paint), falling back to the remembered cache and then the raw value.
  const labelFor = (val: string) =>
    optionsProp.find((option) => option.value === val)?.label ??
    asyncOptions.find((option) => option.value === val)?.label ??
    labels.current.get(val) ??
    val

  // Debounced async search.
  React.useEffect(() => {
    if (!onSearch || !open) return
    let active = true
    setLoading(true)
    const timer = setTimeout(() => {
      onSearch(query).then((result) => {
        if (!active) return
        setAsyncOptions(result)
        setLoading(false)
      })
    }, 300)
    return () => {
      active = false
      clearTimeout(timer)
    }
  }, [query, onSearch, open])

  const atMax = maxSelected != null && value.length >= maxSelected

  const isFixed = (val: string) =>
    optionsProp.find((option) => option.value === val)?.fixed ?? false

  const toggle = (val: string) => {
    if (value.includes(val)) {
      if (isFixed(val)) return
      onChange(value.filter((item) => item !== val))
    } else if (!atMax) {
      onChange([...value, val])
    }
  }

  const create = () => {
    const next = query.trim()
    if (!next || atMax || value.includes(next)) return
    labels.current.set(next, next)
    onChange([...value, next])
    setQuery("")
  }

  const groups = React.useMemo(() => {
    const map = new Map<string, MultiSelectOption[]>()
    for (const option of options) {
      const key = option.group ?? ""
      if (!map.has(key)) map.set(key, [])
      map.get(key)!.push(option)
    }
    return Array.from(map.entries())
  }, [options])

  const canCreate =
    creatable &&
    query.trim() !== "" &&
    !atMax &&
    !options.some(
      (option) => option.label.toLowerCase() === query.trim().toLowerCase()
    ) &&
    !value.includes(query.trim())

  return (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverTrigger asChild>
        <button
          type="button"
          id={id}
          role="combobox"
          aria-expanded={open}
          aria-invalid={ariaInvalid}
          aria-describedby={ariaDescribedby}
          disabled={disabled}
          className={cn(
            "border-input dark:bg-input/30 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 aria-invalid:border-destructive flex min-h-9 w-full items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-1.5 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
            className
          )}
        >
          <span className="flex flex-1 flex-wrap items-center gap-1 text-left">
            {value.length === 0 ? (
              <span className="text-muted-foreground">{placeholder}</span>
            ) : (
              value.map((val) => (
                <Badge key={val} variant="secondary" className="gap-1 pr-1">
                  {labelFor(val)}
                  {!isFixed(val) && (
                    <span
                      role="button"
                      tabIndex={-1}
                      aria-label={`Remove ${labelFor(val)}`}
                      onClick={(event) => {
                        event.stopPropagation()
                        toggle(val)
                      }}
                      onKeyDown={(event) => {
                        if (event.key === "Enter" || event.key === " ") {
                          event.stopPropagation()
                          toggle(val)
                        }
                      }}
                      className="text-muted-foreground hover:text-foreground rounded-full outline-none"
                    >
                      <XIcon className="size-3" />
                    </span>
                  )}
                </Badge>
              ))
            )}
          </span>
          <ChevronsUpDownIcon className="size-4 shrink-0 opacity-50" />
        </button>
      </PopoverTrigger>
      <PopoverContent
        className="w-(--radix-popover-trigger-width) p-0"
        align="start"
      >
        <Command shouldFilter={!onSearch}>
          <CommandInput
            placeholder="Search…"
            value={query}
            onValueChange={setQuery}
          />
          <CommandList>
            {loading ? (
              <div className="text-muted-foreground py-6 text-center text-sm">
                {loadingText}
              </div>
            ) : (
              <>
                {!canCreate && <CommandEmpty>{emptyText}</CommandEmpty>}
                {groups.map(([group, items]) => (
                  <CommandGroup key={group} heading={group || undefined}>
                    {items.map((option) => {
                      const selected = value.includes(option.value)
                      return (
                        <CommandItem
                          key={option.value}
                          value={option.label}
                          disabled={option.disabled || (!selected && atMax)}
                          onSelect={() => toggle(option.value)}
                        >
                          <CheckIcon
                            className={cn(
                              "size-4",
                              selected ? "opacity-100" : "opacity-0"
                            )}
                          />
                          {option.icon}
                          {option.label}
                        </CommandItem>
                      )
                    })}
                  </CommandGroup>
                ))}
                {canCreate && (
                  <CommandGroup>
                    <CommandItem value={query} onSelect={create}>
                      <CheckIcon className="size-4 opacity-0" />
                      Create &ldquo;{query.trim()}&rdquo;
                    </CommandItem>
                  </CommandGroup>
                )}
              </>
            )}
          </CommandList>
        </Command>
      </PopoverContent>
    </Popover>
  )
}
```

## Examples

### Basic

Searchable multi-select with removable badge tokens — built on Popover + Command, controlled with a `string[]` value.

**Install:**

```bash
npx shadcn@latest add @designrevision/multi-select-demo-01
```

**Dependencies:** @designrevision/multi-select, label

```tsx
// components/ui/multi-select-demo-01.tsx
"use client"

import * as React from "react"

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

const frameworks = [
  { value: "next", label: "Next.js" },
  { value: "remix", label: "Remix" },
  { value: "astro", label: "Astro" },
  { value: "nuxt", label: "Nuxt" },
  { value: "svelte", label: "SvelteKit" },
  { value: "solid", label: "SolidStart" },
]

export default function MultiSelectDemo01() {
  const [value, setValue] = React.useState<string[]>(["next", "astro"])

  return (
    <div className="grid w-full max-w-sm gap-2">
      <Label htmlFor="frameworks">Frameworks</Label>
      <MultiSelect
        id="frameworks"
        options={frameworks}
        value={value}
        onChange={setValue}
        placeholder="Select frameworks…"
      />
    </div>
  )
}
```

---

### Grouped

Options organized under group headings via the `group` field on each option.

**Install:**

```bash
npx shadcn@latest add @designrevision/multi-select-grouped-01
```

**Dependencies:** @designrevision/multi-select, label

```tsx
// components/ui/multi-select-grouped-01.tsx
"use client"

import * as React from "react"

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

const options = [
  { value: "react", label: "React", group: "Libraries" },
  { value: "vue", label: "Vue", group: "Libraries" },
  { value: "svelte", label: "Svelte", group: "Libraries" },
  { value: "next", label: "Next.js", group: "Frameworks" },
  { value: "nuxt", label: "Nuxt", group: "Frameworks" },
  { value: "remix", label: "Remix", group: "Frameworks" },
  { value: "vite", label: "Vite", group: "Tooling" },
  { value: "turbopack", label: "Turbopack", group: "Tooling" },
]

export default function MultiSelectGrouped01() {
  const [value, setValue] = React.useState<string[]>(["react", "next"])

  return (
    <div className="grid w-full max-w-sm gap-2">
      <Label htmlFor="stack">Tech stack</Label>
      <MultiSelect
        id="stack"
        options={options}
        value={value}
        onChange={setValue}
        placeholder="Select your stack…"
      />
    </div>
  )
}
```

---

### Creatable tags

Set `creatable` and users can type a new value and press Enter to add it — the classic tags input.

**Install:**

```bash
npx shadcn@latest add @designrevision/multi-select-creatable-01
```

**Dependencies:** @designrevision/multi-select, label

```tsx
// components/ui/multi-select-creatable-01.tsx
"use client"

import * as React from "react"

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

const suggested = [
  { value: "design", label: "Design" },
  { value: "engineering", label: "Engineering" },
  { value: "marketing", label: "Marketing" },
  { value: "sales", label: "Sales" },
]

export default function MultiSelectCreatable01() {
  const [value, setValue] = React.useState<string[]>(["design"])

  return (
    <div className="grid w-full max-w-sm gap-2">
      <Label htmlFor="tags">Tags</Label>
      <MultiSelect
        id="tags"
        options={suggested}
        value={value}
        onChange={setValue}
        creatable
        placeholder="Add tags…"
      />
      <p className="text-sm text-muted-foreground">
        Type a new tag and press Enter to create it.
      </p>
    </div>
  )
}
```

---

### Max items

Cap the selection with `maxSelected`; once the limit is hit, the remaining options disable.

**Install:**

```bash
npx shadcn@latest add @designrevision/multi-select-max-01
```

**Dependencies:** @designrevision/multi-select, label

```tsx
// components/ui/multi-select-max-01.tsx
"use client"

import * as React from "react"

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

const interests = [
  { value: "ai", label: "AI & ML" },
  { value: "web", label: "Web Development" },
  { value: "mobile", label: "Mobile" },
  { value: "devops", label: "DevOps" },
  { value: "design", label: "Design" },
  { value: "data", label: "Data" },
]

export default function MultiSelectMax01() {
  const maxSelected = 3
  const [value, setValue] = React.useState<string[]>(["ai", "web"])

  return (
    <div className="grid w-full max-w-sm gap-2">
      <Label htmlFor="interests">Interests</Label>
      <MultiSelect
        id="interests"
        options={interests}
        value={value}
        onChange={setValue}
        maxSelected={maxSelected}
        placeholder="Pick up to 3…"
      />
      <p className="text-sm text-muted-foreground tabular-nums">
        {value.length}/{maxSelected} selected
      </p>
    </div>
  )
}
```

---

### Async search

Pass `onSearch` to fetch options per debounced query, with a loading state — for directories and API-backed lists.

**Install:**

```bash
npx shadcn@latest add @designrevision/multi-select-async-01
```

**Dependencies:** @designrevision/multi-select, label

```tsx
// components/ui/multi-select-async-01.tsx
"use client"

import * as React from "react"

import { Label } from "@/components/ui/label"
import { MultiSelect, type MultiSelectOption } from "@/components/ui/multi-select"

const directory: MultiSelectOption[] = [
  { value: "ada", label: "Ada Lovelace" },
  { value: "alan", label: "Alan Turing" },
  { value: "grace", label: "Grace Hopper" },
  { value: "katherine", label: "Katherine Johnson" },
  { value: "linus", label: "Linus Torvalds" },
  { value: "margaret", label: "Margaret Hamilton" },
  { value: "tim", label: "Tim Berners-Lee" },
  { value: "barbara", label: "Barbara Liskov" },
]

// Simulates an API call.
function searchPeople(query: string): Promise<MultiSelectOption[]> {
  return new Promise((resolve) => {
    setTimeout(() => {
      const q = query.toLowerCase()
      resolve(directory.filter((person) => person.label.toLowerCase().includes(q)))
    }, 600)
  })
}

export default function MultiSelectAsync01() {
  const [value, setValue] = React.useState<string[]>([])

  return (
    <div className="grid w-full max-w-sm gap-2">
      <Label htmlFor="people">Assignees</Label>
      <MultiSelect
        id="people"
        options={[]}
        value={value}
        onChange={setValue}
        onSearch={searchPeople}
        placeholder="Search people…"
        loadingText="Searching…"
      />
    </div>
  )
}
```

---

### React Hook Form

A required skills field validated with `react-hook-form` and a `zod` array schema (`min(1)`), with an `aria-invalid` error.

**Install:**

```bash
npx shadcn@latest add @designrevision/multi-select-form-01
```

**Dependencies:** react-hook-form, zod, @hookform/resolvers, @designrevision/multi-select, label, button

```tsx
// components/ui/multi-select-form-01.tsx
"use client"

import * as React from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { z } from "zod"

import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import { MultiSelect } from "@/components/ui/multi-select"

const schema = z.object({
  skills: z.array(z.string()).min(1, "Select at least one skill."),
})

type FormValues = z.infer<typeof schema>

const skills = [
  { value: "react", label: "React" },
  { value: "typescript", label: "TypeScript" },
  { value: "node", label: "Node.js" },
  { value: "css", label: "CSS" },
  { value: "graphql", label: "GraphQL" },
]

export default function MultiSelectForm01() {
  const [submitted, setSubmitted] = React.useState(false)
  const {
    handleSubmit,
    setValue,
    watch,
    formState: { errors },
  } = useForm<FormValues>({
    resolver: zodResolver(schema),
    defaultValues: { skills: [] },
  })

  const selected = watch("skills")

  return (
    <form
      onSubmit={handleSubmit(() => setSubmitted(true))}
      className="grid w-full max-w-sm gap-4"
    >
      <div className="grid gap-2">
        <Label htmlFor="skills">Skills</Label>
        <MultiSelect
          id="skills"
          options={skills}
          value={selected}
          onChange={(value) =>
            setValue("skills", value, { shouldValidate: true })
          }
          placeholder="Select your skills…"
          aria-invalid={!!errors.skills}
          aria-describedby={errors.skills ? "skills-error" : undefined}
        />
        {errors.skills ? (
          <p id="skills-error" className="text-sm text-destructive">
            {errors.skills.message}
          </p>
        ) : null}
      </div>
      <Button type="submit" className="w-fit">
        {submitted ? "Submitted!" : "Continue"}
      </Button>
    </form>
  )
}
```

---

### With avatars

Rich options — each carries an `icon` (an avatar) rendered before the label in the list.

**Install:**

```bash
npx shadcn@latest add @designrevision/multi-select-rich-01
```

**Dependencies:** @designrevision/multi-select, label

```tsx
// components/ui/multi-select-rich-01.tsx
"use client"

import * as React from "react"

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

function Avatar({ initials, className }: { initials: string; className: string }) {
  return (
    <span
      className={`flex size-5 shrink-0 items-center justify-center rounded-full text-[10px] font-medium text-white ${className}`}
    >
      {initials}
    </span>
  )
}

const members = [
  { value: "alice", label: "Alice Johnson", icon: <Avatar initials="AJ" className="bg-rose-500" /> },
  { value: "ben", label: "Ben Carter", icon: <Avatar initials="BC" className="bg-blue-500" /> },
  { value: "chloe", label: "Chloe Diaz", icon: <Avatar initials="CD" className="bg-emerald-500" /> },
  { value: "dan", label: "Dan Evans", icon: <Avatar initials="DE" className="bg-amber-500" /> },
  { value: "erin", label: "Erin Frost", icon: <Avatar initials="EF" className="bg-violet-500" /> },
]

export default function MultiSelectRich01() {
  const [value, setValue] = React.useState<string[]>(["alice", "chloe"])

  return (
    <div className="grid w-full max-w-sm gap-2">
      <Label htmlFor="reviewers">Reviewers</Label>
      <MultiSelect
        id="reviewers"
        options={members}
        value={value}
        onChange={setValue}
        placeholder="Add reviewers…"
      />
    </div>
  )
}
```

---

### States

A `fixed` option that cannot be removed, a `disabled` option that cannot be selected, and a fully disabled field.

**Install:**

```bash
npx shadcn@latest add @designrevision/multi-select-disabled-01
```

**Dependencies:** @designrevision/multi-select, label

```tsx
// components/ui/multi-select-disabled-01.tsx
"use client"

import * as React from "react"

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

const permissions = [
  { value: "read", label: "Read", fixed: true },
  { value: "write", label: "Write" },
  { value: "comment", label: "Comment" },
  { value: "delete", label: "Delete", disabled: true },
]

export default function MultiSelectDisabled01() {
  const [value, setValue] = React.useState<string[]>(["read", "write"])

  return (
    <div className="grid w-full max-w-sm gap-6">
      <div className="grid gap-2">
        <Label htmlFor="permissions">Permissions</Label>
        <MultiSelect
          id="permissions"
          options={permissions}
          value={value}
          onChange={setValue}
        />
        <p className="text-sm text-muted-foreground">
          “Read” is fixed; “Delete” is disabled.
        </p>
      </div>
      <div className="grid gap-2">
        <Label htmlFor="permissions-disabled" className="text-muted-foreground">
          Disabled
        </Label>
        <MultiSelect
          id="permissions-disabled"
          options={permissions}
          value={["read", "write"]}
          onChange={() => {}}
          disabled
        />
      </div>
    </div>
  )
}
```
