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.
Installation
{
"registries": {
"@designrevision": "https://registry.designrevision.com/r/{name}.json"
}
}
Add to your existing components.json. Requires shadcn/ui v2.3+.
Packages
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.
"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
Props
No props documented yet.
"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
Props
No props documented yet.
"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
Props
No props documented yet.
"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
Props
No props documented yet.
"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
Props
No props documented yet.
"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
Props
No props documented yet.
"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
Props
No props documented yet.
"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
Props
No props documented yet.
"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>
)
}
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 and a combobox pattern, but no multi-select component; this one fills that gap, built the shadcn way on the Popover 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 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 React Hook Form 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 with htmlFor/id, and link any error message with aria-describedby.
Theming
The multi-select is assembled from the themed Popover, Command, and Badge primitives, so it reads your design tokens (--primary, --popover, --input, --ring) and follows your theme — including dark mode — with zero overrides.