Multi select
Several picks shown as chips in a pill field, with a checked list in a popover.
npx shadcn@latest add https://ui.goodtabs.co/r/multi-select.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/multi-select
DesignFrontend
Six tags with two chosen. Each chip has a remove cross, the list has a count and Clear.
Many selected
Reviewers
Mira ChenTom Okafor+3
Five reviewers with maxChips 2, so three fold into a +3 chip. Controlled through value.
Not searchable
TuesdayThursday
Five days with no filter field above the list.
Usage
import { MultiSelect } from "@/components/ui/multi-select"
<MultiSelect
options={[
{ value: "design", label: "Design" },
{ value: "frontend", label: "Frontend" },
]}
defaultValue={["design"]}
placeholder="Add tags"
aria-label="Tags"
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| options | { value: string; label: string; disabled?: boolean }[] | The choices, in order. | |
| value | string[] | Controlled values. | |
| defaultValue | string[] | [] | Initial values when uncontrolled. |
| onValueChange | (value: string[]) => void | Called with the new array. | |
| placeholder | string | "Select..." | Field text when nothing is chosen. |
| maxChips | number | 3 | Chips shown before the rest collapse into +N. |
| searchable | boolean | true | Show the filter field above the list. |
| searchPlaceholder | string | "Search..." | Placeholder of the filter field. |
| disabled | boolean | false | Disables the field. |
Source
multi-select/multi-select.tsx
"use client";
import * as React from "react";
import { CheckIcon, ChevronDownIcon, XIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Command, CommandEmpty, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
export type MultiSelectOption = {
/** The value kept in the array. */
value: string;
/** Text shown in the list and on the chip. */
label: string;
/** Shown but not choosable. */
disabled?: boolean;
};
type MultiSelectProps = Omit<React.ComponentProps<"div">, "defaultValue" | "onChange"> & {
/** The choices, in order. */
options: MultiSelectOption[];
/** Controlled values. */
value?: string[];
/** Initial values when uncontrolled. */
defaultValue?: string[];
/** Called with the new array. */
onValueChange?: (value: string[]) => void;
/** Field text when nothing is chosen. */
placeholder?: string;
/** Chips shown before the rest collapse into +N. */
maxChips?: number;
/** Show the filter field above the list. */
searchable?: boolean;
/** Placeholder of the filter field. */
searchPlaceholder?: string;
/** Disables the field. */
disabled?: boolean;
};
/**
* Several picks from a list. The field shows chips with a remove cross and folds extras into +N;
* the popover lists every option with a check box, a count and a Clear action.
*/
export function MultiSelect({
options,
value: valueProp,
defaultValue,
onValueChange,
placeholder = "Select...",
maxChips = 3,
searchable = true,
searchPlaceholder = "Search...",
disabled,
className,
...props
}: MultiSelectProps) {
const [open, setOpen] = React.useState(false);
const [inner, setInner] = React.useState<string[]>(defaultValue ?? []);
const value = valueProp ?? inner;
const fieldRef = React.useRef<HTMLDivElement>(null);
const listId = React.useId();
const set = (next: string[]) => {
if (valueProp === undefined) setInner(next);
onValueChange?.(next);
};
const toggle = (v: string) => set(value.includes(v) ? value.filter((x) => x !== v) : [...value, v]);
const remove = (v: string) => set(value.filter((x) => x !== v));
const chosen = value.map((v) => options.find((o) => o.value === v)).filter((o): o is MultiSelectOption => !!o);
const shown = chosen.slice(0, maxChips);
const hidden = chosen.length - shown.length;
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.target !== event.currentTarget) return;
if (event.key === "Enter" || event.key === " " || event.key === "ArrowDown") {
event.preventDefault();
setOpen(true);
} else if (event.key === "Backspace" && value.length) {
event.preventDefault();
set(value.slice(0, -1));
}
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverAnchor asChild>
<div
ref={fieldRef}
role="combobox"
tabIndex={disabled ? -1 : 0}
aria-expanded={open}
aria-haspopup="listbox"
aria-controls={open ? listId : undefined}
aria-disabled={disabled || undefined}
data-slot="multi-select"
data-state={open ? "open" : "closed"}
onClick={() => !disabled && setOpen((o) => !o)}
onKeyDown={onKeyDown}
className={cn(
"flex h-8 w-full cursor-default items-center gap-1 rounded-full border border-border bg-background py-0 pr-2 pl-1 text-sm transition-colors duration-100 ease-out outline-none select-none hover:bg-muted focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 data-[state=open]:bg-muted dark:border-input dark:bg-input/30 dark:hover:bg-input/50 dark:data-[state=open]:bg-input/50",
disabled && "pointer-events-none opacity-50",
className,
)}
{...props}
>
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
{chosen.length === 0 && <span className="truncate pl-1.5 text-muted-foreground">{placeholder}</span>}
{shown.map((option) => (
<span
key={option.value}
className="inline-flex h-6 shrink-0 items-center gap-0.5 rounded-full bg-muted pr-0.5 pl-2 text-xs text-foreground"
>
<span className="max-w-32 truncate">{option.label}</span>
<button
type="button"
tabIndex={-1}
aria-label={`Remove ${option.label}`}
onClick={(event) => {
event.stopPropagation();
remove(option.value);
}}
className="grid size-5 shrink-0 place-items-center rounded-full text-foreground/70 transition-colors duration-100 ease-out outline-none hover:bg-foreground/10 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50"
>
<XIcon className="size-3" aria-hidden="true" />
</button>
</span>
))}
{hidden > 0 && (
<span className="inline-flex h-6 shrink-0 items-center rounded-full bg-muted px-2 font-mono text-xs text-foreground">
+{hidden}
</span>
)}
</div>
<ChevronDownIcon className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
</div>
</PopoverAnchor>
<PopoverContent
align="start"
className="w-(--radix-popover-trigger-width) min-w-48 gap-0 rounded-md border p-0 ring-0"
onOpenAutoFocus={(event) => {
if (!searchable) event.preventDefault();
}}
onCloseAutoFocus={(event) => {
event.preventDefault();
fieldRef.current?.focus();
}}
onInteractOutside={(event) => {
if (fieldRef.current?.contains(event.target as Node)) event.preventDefault();
}}
>
<Command>
{searchable && <CommandInput placeholder={searchPlaceholder} />}
<CommandList id={listId}>
<CommandEmpty className="text-muted-foreground">No results</CommandEmpty>
{options.map((option) => {
const checked = value.includes(option.value);
return (
<CommandItem
key={option.value}
value={option.label}
keywords={[option.value]}
disabled={option.disabled}
onSelect={() => toggle(option.value)}
className="h-8 py-0"
>
<span
aria-hidden="true"
className={cn(
"grid size-4 shrink-0 place-items-center rounded-[4px] border border-input transition-colors duration-100 ease-out",
checked && "border-primary bg-primary text-primary-foreground",
)}
>
{checked && <CheckIcon className="size-3.5" />}
</span>
<span className="truncate">{option.label}</span>
<span className="sr-only">{checked ? ", selected" : ""}</span>
</CommandItem>
);
})}
</CommandList>
<div className="flex h-8 items-center justify-between border-t px-2 text-xs text-muted-foreground">
<span aria-live="polite">{value.length} selected</span>
<Button
type="button"
variant="ghost"
size="xs"
disabled={value.length === 0}
onClick={() => set([])}
className="-mr-1 rounded-full"
>
Clear
</Button>
</div>
</Command>
</PopoverContent>
</Popover>
);
}
For AI agents
Hand your agent this page, the markdown at /components/multi-select/prompt.md, or the Copy AI prompt button above.
When to use
- Tags, labels, reviewers, channels: any set where several picks are normal
- Filters where the chosen values should stay visible in the field
- Lists long enough that typing to filter helps
When not to use
- One pick only; use the searchable select
- Four options or fewer that fit on one line; use a checkbox list or toggle group
- Free text entries that are not in the list; use a tag input
Agent notes
- Works controlled (value, onValueChange) or uncontrolled (defaultValue); it takes functions, so render it from a client component
- The field is a focusable div with role combobox so chips can hold real remove buttons; Enter, Space or ArrowDown open it, Backspace removes the last pick
- Chips never wrap: the field stays 32px tall and folds extras into +N after maxChips
- The popover matches the field width and stays open while picking; the footer shows the count and clears everything