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.

Installation

1 Configure registry — once per project
{
  "registries": {
    "@designrevision": "https://registry.designrevision.com/r/{name}.json"
  }
}

Add to your existing components.json. Requires shadcn/ui v2.3+.

2 Install the component

Packages

lucide-react
popover
command
badge
utils

Props

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 = false
boolean

Allow creating new entries from the search query.

onSearch = —
(query: string) => Promise<MultiSelectOption[]>

Async option source — when set, options are fetched per (debounced) query.

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.

Copied!
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 “{query.trim()}”
                    </CommandItem>
                  </CommandGroup>
                )}
              </>
            )}
          </CommandList>
        </Command>
      </PopoverContent>
    </Popover>
  )
}

Examples

Basic

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

Packages

@designrevision/multi-select
label

Props

No props documented yet.

Copied!
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.

Packages

@designrevision/multi-select
label

Props

No props documented yet.

Copied!
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.

Packages

@designrevision/multi-select
label

Props

No props documented yet.

Copied!
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.

Packages

@designrevision/multi-select
label

Props

No props documented yet.

Copied!
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.

Packages

@designrevision/multi-select
label

Props

No props documented yet.

Copied!
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.

Packages

react-hook-form
zod
@hookform/resolvers
@designrevision/multi-select
label
button

Props

No props documented yet.

Copied!
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.

Packages

@designrevision/multi-select
label

Props

No props documented yet.

Copied!
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.

Packages

@designrevision/multi-select
label

Props

No props documented yet.

Copied!
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>
  )
}

Frequently asked questions

No — official shadcn/ui ships a single-value Select and a Combobox pattern, but no multi-select component. This is a drop-in multi-select built the shadcn way, on the same Popover + Command primitives, so it matches your existing components and theme.
Use this multi-select when users pick several values from a long, searchable list — it shows the choices as removable tokens. A combobox is the single-value version of the same searchable pattern. A [checkbox](/components/checkbox) group is better for a short, always-visible set of independent options.
Set the creatable prop. When the typed query does not match an existing option, a "Create …" row appears; selecting it (or pressing Enter) adds the typed value to the selection. This is how you build a tags input.
Pass an onSearch function that returns a Promise of options. The component debounces the query, shows a loading state, and renders whatever you resolve — labels of already-selected items are remembered so the tokens stay readable.
Store the field as a string array. Drive the component value from the field and write it back with setValue from onChange. Validate with a zod array schema such as z.array(z.string()).min(1), and set aria-invalid for the error styling. The React Hook Form example is complete.
Yes. It is built from the themed Popover, Command, and Badge primitives, so it reads your CSS variable tokens and follows your theme automatically, including dark mode and any shadcn-compatible palette.