Multi select
A select that holds several values at once, showing them as chips over a searchable list.
Controlled only; the search text is the one thing it keeps to itself. The X on a trigger chip is a span rather than a button — a button nested inside the trigger would be closed early by the HTML parser and hydration would then disagree — and it is aria-hidden, so it is a pointer shortcut only. There are two keyboard paths to the same result: Enter on an already-checked option in the list, and Backspace on an empty search, which takes the last chip back off the way it works in an email To field. cmdk owns aria-selected for the highlighted row, so whether an option is chosen is its own aria-checked. The Create row is only offered when creatable is paired with an onCreate to receive it. Options carrying a group land under a heading, in the order the groups first appear.
Install
npx shadcn@latest add @vibra/multi-selectNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { MultiSelect } from "@/components/ui/multi-select"
const TEAMS = [
{ value: "platform", label: "Platform", group: "Engineering" },
{ value: "payments", label: "Payments", group: "Engineering" },
{ value: "data", label: "Data", group: "Engineering" },
{ value: "brand", label: "Brand", group: "Go to market" },
{ value: "field-sales", label: "Field sales", group: "Go to market" },
{ value: "support", label: "Support", group: "Go to market" },
{ value: "legal", label: "Legal", group: "Operations", disabled: true },
]
export default function MultiSelectDemo() {
const [teams, setTeams] = React.useState<string[]>(["payments", "data"])
return (
<div className="flex w-full max-w-sm flex-col gap-2">
<span className="text-sm font-medium">Notify teams</span>
<MultiSelect
aria-label="Notify teams"
options={TEAMS}
value={teams}
onValueChange={setTeams}
placeholder="Everyone on call"
searchPlaceholder="Search teams"
/>
<p className="text-xs text-muted-foreground">
Legal is set by policy and cannot be changed here.
</p>
</div>
)
}Creatable, with overflow
Adding a label that does not exist yet, with the chips collapsing past two.
"use client"
import * as React from "react"
import { MultiSelect, type MultiSelectOption } from "@/components/ui/multi-select"
const STARTING_LABELS: MultiSelectOption[] = [
{ value: "churn-risk", label: "Churn risk" },
{ value: "expansion", label: "Expansion" },
{ value: "security-review", label: "Security review" },
{ value: "migrating", label: "Migrating" },
]
export default function MultiSelectCreatable() {
const [options, setOptions] = React.useState(STARTING_LABELS)
const [labels, setLabels] = React.useState<string[]>(["expansion"])
function create(label: string) {
const value = label.toLowerCase().replace(/\s+/g, "-")
if (!options.some((option) => option.value === value))
setOptions([...options, { value, label }])
setLabels([...labels, value])
}
return (
<div className="flex w-full max-w-sm flex-col gap-2">
<span className="text-sm font-medium">Account labels</span>
<MultiSelect
aria-label="Account labels"
options={options}
value={labels}
onValueChange={setLabels}
creatable
onCreate={create}
maxDisplay={2}
placeholder="No labels"
searchPlaceholder="Search or add a label"
emptyMessage="Nothing matches yet."
/>
<p className="text-xs text-muted-foreground">
Type a name that does not exist yet to add it to the list.
</p>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| options | { value: string; label: string; icon?: React.ReactNode; disabled?: boolean; group?: string }[] | — | The choices; a group puts the option under a heading. |
| value | string[] | — | The values currently chosen, in chip order. |
| onValueChange | (value: string[]) => void | — | Called with the whole new selection. |
| placeholder | string | "Select options" | Stands in for the chips while nothing is chosen. |
| searchPlaceholder | string | "Search" | Placeholder for the search field inside the popover. |
| maxDisplay | number | 3 | How many chips fit on the trigger before the rest collapse into "+N". |
| creatable | boolean | false | Offers to add whatever was typed when no label matches it; needs onCreate to do anything. |
| onCreate | (label: string) => void | — | Called with the typed text; add the option and select it yourself. |
| disabled | boolean | false | Dims the trigger and stops it opening. |
| size | "sm" | "default" | "default" | sm drops the trigger to h-7 for dense toolbars. |
| emptyMessage | React.ReactNode | "No results found." | Shown when the search matches nothing. |
| aria-label | string | — | Names the trigger, e.g. Teams; the chips alone read as a list of values. |
| className | string | — | Merged onto the trigger, which is the root; the remaining button props are spread onto it too. |
Dependencies
Registry
npm
Source
"use client"
import * as React from "react"
import { ChevronDownIcon, 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
/** Sits before the label in the list; sized to 4 unless it sets its own size. */
icon?: React.ReactNode
disabled?: boolean
/** Puts the option under a heading; ungrouped options come first. */
group?: string
}
// The trigger button is the root: className and the rest of the props land on
// it. The names below are re-declared because the item owns them — `value` is
// the chosen ids, and size is the item's density axis.
export type MultiSelectProps = Omit<
React.ComponentProps<"button">,
"value" | "size" | "disabled" | "aria-label" | "children" | "type"
> & {
options: MultiSelectOption[]
value: string[]
onValueChange: (value: string[]) => void
placeholder?: string
searchPlaceholder?: string
/** How many chips fit on the trigger before the rest collapse into "+N". */
maxDisplay?: number
/** Offers to add whatever was typed when nothing matches it. */
creatable?: boolean
onCreate?: (label: string) => void
disabled?: boolean
size?: "sm" | "default"
className?: string
emptyMessage?: React.ReactNode
/** Names the trigger, e.g. "Teams"; the chips alone read as a list of values. */
"aria-label"?: string
}
/** A select that holds several values at once, showing them as chips over a searchable list. */
function MultiSelect({
options,
value,
onValueChange,
placeholder = "Select options",
searchPlaceholder = "Search",
maxDisplay = 3,
creatable = false,
onCreate,
disabled = false,
size = "default",
className,
emptyMessage = "No results found.",
"aria-label": ariaLabel,
...props
}: MultiSelectProps) {
const [open, setOpen] = React.useState(false)
const [search, setSearch] = React.useState("")
const selected = new Set(value)
const byValue = new Map(options.map((option) => [option.value, option]))
const chosen = value.map((item) => byValue.get(item)?.label ?? item)
const shown = chosen.slice(0, maxDisplay)
const overflow = chosen.length - shown.length
// Groups keep the order they first appear in, and ungrouped options lead.
const groups: { name: string | undefined; options: MultiSelectOption[] }[] = []
for (const option of options) {
const group = groups.find((entry) => entry.name === option.group)
if (group) group.options.push(option)
else groups.push({ name: option.group, options: [option] })
}
const query = search.trim()
// No onCreate means nothing can come of the row, so it is not offered.
const canCreate =
creatable &&
onCreate !== undefined &&
query.length > 0 &&
!options.some((option) => option.label.toLowerCase() === query.toLowerCase())
function toggle(optionValue: string) {
onValueChange(
selected.has(optionValue)
? value.filter((item) => item !== optionValue)
: [...value, optionValue]
)
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<button
type="button"
data-slot="multi-select"
data-size={size}
data-count={value.length}
disabled={disabled}
aria-label={ariaLabel}
className={cn(
"flex w-full min-w-0 items-center gap-1.5 overflow-hidden rounded-lg border border-input bg-transparent px-2.5 text-sm transition-colors focus-ring disabled:pointer-events-none disabled:opacity-50 dark:bg-input/30 dark:hover:bg-input/50",
size === "sm" ? "h-7" : "h-8",
className
)}
{...props}
>
{value.length === 0 ? (
<span className="truncate text-muted-foreground">{placeholder}</span>
) : (
<span className="flex min-w-0 items-center gap-1">
{shown.map((label, index) => (
<Badge
key={value[index]}
variant="secondary"
className="max-w-28 gap-0.5 rounded-sm pe-0.5 font-normal"
>
<span className="truncate">{label}</span>
{/* A span, not a button: a <button> here would be nested
inside the trigger, which the HTML parser closes early
and hydration then disagrees about. The keyboard path to
the same result is the list itself, or Backspace in an
empty search. */}
<span
data-slot="multi-select-chip-remove"
aria-hidden="true"
className="inline-flex size-3.5 shrink-0 items-center justify-center rounded-[3px] text-muted-foreground transition-colors hover:bg-foreground/10 hover:text-foreground"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
toggle(value[index])
}}
>
<XIcon className="size-3!" />
</span>
</Badge>
))}
{overflow > 0 ? (
<Badge variant="secondary" className="rounded-sm font-normal tabular-nums">
+{overflow}
</Badge>
) : null}
</span>
)}
<ChevronDownIcon className="ms-auto size-4 shrink-0 text-muted-foreground" />
</button>
}
/>
{/* COUPLED TO registry/vibra/ui/popover.tsx: the popover ships a padded
w-72 panel; this one matches the trigger and lets the list pad itself. */}
<PopoverContent align="start" className="w-(--anchor-width) min-w-52 gap-0 p-0">
<Command>
<CommandInput
value={search}
onValueChange={setSearch}
placeholder={searchPlaceholder}
onKeyDown={(event) => {
// Backspace on an empty search takes the last chip back off,
// the way it works in an email "To" field.
if (event.key === "Backspace" && search === "" && value.length > 0) {
event.preventDefault()
onValueChange(value.slice(0, -1))
}
}}
/>
<CommandList aria-multiselectable="true">
{canCreate ? null : <CommandEmpty>{emptyMessage}</CommandEmpty>}
{groups.map((group) => (
<CommandGroup key={group.name ?? "__ungrouped"} heading={group.name}>
{group.options.map((option) => {
const isSelected = selected.has(option.value)
return (
<CommandItem
key={option.value}
value={option.label}
disabled={option.disabled}
onSelect={() => toggle(option.value)}
// cmdk's aria-selected marks the highlighted row; whether
// an option is chosen is its own attribute.
aria-checked={isSelected}
data-checked={isSelected ? "true" : undefined}
>
{option.icon ? (
<span aria-hidden="true" className="text-muted-foreground">
{option.icon}
</span>
) : null}
<span className="truncate">{option.label}</span>
</CommandItem>
)
})}
</CommandGroup>
))}
{canCreate ? (
<CommandGroup forceMount>
<CommandItem
forceMount
value={`__create__${query}`}
onSelect={() => {
onCreate(query)
setSearch("")
}}
>
<span className="truncate">
Create "<span className="font-medium">{query}</span>"
</span>
</CommandItem>
</CommandGroup>
) : null}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
export { MultiSelect }