Multi Select
A multi-select combobox that toggles values in and out of a selection array, with inline search and full controlled/uncontrolled API.
import { useState } from "react";import { MultiSelect } from "@/components/ui/multi-select"";import type { SelectionOption } from "@/registry/base/use-filtered-options/hooks/use-filtered-options";import { Search } from "lucide-react";
type Skill = | "react" | "typescript" | "nodejs" | "python" | "docker" | "graphql" | "postgres" | "redis";
const skills: SelectionOption<Skill>[] = [ { value: "react", label: "React" }, { value: "typescript", label: "TypeScript" }, { value: "nodejs", label: "Node.js" }, { value: "python", label: "Python" }, { value: "docker", label: "Docker" }, { value: "graphql", label: "GraphQL" }, { value: "postgres", label: "PostgreSQL" }, { value: "redis", label: "Redis" },];
const skillMap = new Map(skills.map((s) => [s.value, s]));
export function BasicMultiSelect() { const [selected, setSelected] = useState<Skill[]>( skills.slice(0, 3).map((s) => s.value), );
return ( <div className="flex w-72 flex-col gap-3"> <MultiSelect items={skills} value={selected} onValueChange={setSelected} placeholder="Select skills..." startAddon={<Search />} endAddon={<span className="w-fit">{selected.length} selected</span>} /> {selected.length > 0 ? ( <div className="flex flex-wrap gap-1.5"> {selected.map((v) => ( <span key={v} className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground" > {skillMap.get(v)?.label} </span> ))} </div> ) : ( <p className="text-sm text-muted-foreground">No skills selected.</p> )} </div> );}Installation
Section titled “Installation”This component relies on other items which must be installed first
Install the following dependencies
Copy and paste the following code into your project.
components/ui/multi-select.tsx
import { useCallback, useMemo, type ComponentProps, type ReactNode,} from "react";import { Command as CommandPrimitive } from "cmdk";import { Check } from "lucide-react";
import { Popover, PopoverAnchor, PopoverContent,} from "@/components/ui/popover";import { Command, CommandEmpty, CommandGroup, CommandItem, CommandList,} from "@/components/ui/command";import { InputGroup, InputGroupAddon, InputGroupInput,} from "@/components/ui/input-group";import { Skeleton } from "@/components/ui/skeleton";import { cn } from "@/lib/utils";
import { useControlledState } from "@/hooks/use-controlled-state";import { useFilteredOptions, type SelectionOption,} from "@/hooks/use-filtered-options";
type MultiSelectProps<T extends string> = { /** Full list of options to display. */ items: SelectionOption<T>[];
/** Controlled array of currently selected values. */ value: T[]; /** Called with the full updated selection array after each toggle. */ onValueChange: (value: T[]) => void;
/** Controlled open state of the dropdown. Omit to let the component manage it internally. */ open?: boolean; /** Called whenever the open state changes. */ onOpenChange?: (open: boolean) => void;
/** Controlled search query. Omit to let the component manage query state internally. */ query?: string; /** Called whenever the search query changes. */ onQueryChange?: (query: string) => void;
/** * Custom renderer for each list item. * Receives the option and a boolean indicating whether it is currently selected. * When omitted a default label + checkmark layout is used. */ renderOption?: (option: SelectionOption<T>, selected: boolean) => ReactNode;
/** Placeholder shown in the search input when empty. @default "Search..." */ placeholder?: string;
/** Content shown when no options match the current query. @default "No results found" */ emptyContent?: ReactNode; /** * Content shown in place of the option list while `isLoading` is `true`. * Defaults to a single skeleton bar. */ loadingContent?: ReactNode;
/** * Custom filter predicate. Receives each option and the current query string. * Replaces the default case-insensitive label match when provided. */ filterFn?: (item: SelectionOption<T>, query: string) => boolean; /** * Skip local filtering entirely. * Use when `items` are already filtered server-side for the current query. * @default false */ disableLocalFilter?: boolean;
/** Render the loading skeleton in place of the option list. */ isLoading?: boolean;
/** Clear the search query after each selection toggle. @default true */ clearQueryOnSelect?: boolean; /** Close the dropdown immediately after each selection toggle. @default false */ closeAfterSelect?: boolean;
/** Content rendered as a leading addon inside the input (e.g. a search icon or label). */ startAddon?: ReactNode; /** Content rendered as a trailing addon inside the input (e.g. a selected-count badge or clear button). */ endAddon?: ReactNode;} & ComponentProps<typeof PopoverContent>;
/** * Multi-select combobox that toggles values in and out of a selection array. * * **Required:** `value`, `onValueChange`, `items` * * Supports controlled `open` and `query` state, async loading, custom option rendering, * and custom filtering. Embed icons or actions inside the input via `startAddon` / `endAddon`. */export function MultiSelect<T extends string>({ items, value, onValueChange, open: openProp, onOpenChange, query: queryProp, onQueryChange, renderOption, placeholder = "Search...", emptyContent = "No results found", loadingContent, filterFn, disableLocalFilter = false, isLoading, clearQueryOnSelect = true, closeAfterSelect = false, startAddon, endAddon, ...props}: MultiSelectProps<T>) { const [values, setValues] = useControlledState({ value, defaultValue: [], onChange: onValueChange, });
const [open, setOpen] = useControlledState({ value: openProp, defaultValue: false, onChange: onOpenChange, });
const [query, setQuery] = useControlledState({ value: queryProp, defaultValue: "", onChange: onQueryChange, });
const selectedSet = useMemo(() => new Set(values), [values]);
const filteredItems = useFilteredOptions({ items, query, filterFn, disableLocalFilter, });
const toggleValue = useCallback( (nextValue: string) => { const v = nextValue as T;
const next = selectedSet.has(v) ? values.filter((item) => item !== v) : [...values, v];
setValues(next);
if (clearQueryOnSelect) { setQuery(""); }
if (closeAfterSelect) { setOpen(false); } }, [ values, selectedSet, setValues, clearQueryOnSelect, setQuery, closeAfterSelect, setOpen, ], );
return ( <div data-slot="combobox-content" className="relative w-full"> <Popover open={open} onOpenChange={setOpen}> <Command shouldFilter={false}> <PopoverAnchor asChild> <InputGroup> {startAddon && ( <InputGroupAddon align="inline-start"> {startAddon} </InputGroupAddon> )} <CommandPrimitive.Input asChild value={query} onValueChange={setQuery} onMouseDown={() => setOpen(true)} onKeyDown={(e) => { if (e.key === "Escape") { if (open) { setOpen(false); } else { setQuery(""); } return; } setOpen(true); }} > <InputGroupInput placeholder={placeholder} onFocus={() => setOpen(true)} /> </CommandPrimitive.Input> {endAddon && ( <InputGroupAddon align="inline-end">{endAddon}</InputGroupAddon> )} </InputGroup> </PopoverAnchor>
{!open && <CommandList aria-hidden="true" className="hidden" />}
<PopoverContent asChild className="w-(--radix-popover-trigger-width) p-0" onInteractOutside={(event) => { if ( event.target instanceof Element && event.target.hasAttribute("cmdk-input") ) { event.preventDefault(); } }} {...props} > <CommandList> {isLoading ? ( <CommandPrimitive.Loading> {loadingContent ?? ( <div className="p-2"> <Skeleton className="h-6 w-full" /> </div> )} </CommandPrimitive.Loading> ) : filteredItems.length ? ( <CommandGroup> {filteredItems.map((item) => { const isSelected = selectedSet.has(item.value);
return ( <CommandItem key={item.value} value={item.value} disabled={item.disabled} onSelect={toggleValue} onMouseDown={(e) => e.preventDefault()} > {renderOption ? ( renderOption(item, isSelected) ) : ( <> <Check className={cn( "mr-2 h-4 w-4", isSelected ? "opacity-100" : "opacity-0", )} /> {item.label} </> )} </CommandItem> ); })} </CommandGroup> ) : ( <CommandEmpty>{emptyContent}</CommandEmpty> )} </CommandList> </PopoverContent> </Command> </Popover> </div> );}hooks/use-filtered-options.ts
import { useMemo } from "react";
export type SelectionOption<T extends string> = { /** Unique identifier for this option, used as the cmdk `value`. */ value: T; /** Display string shown in the list and the search input. */ label: string; /** When `true`, the item renders but cannot be selected. @default false */ disabled?: boolean;};
type UseFilteredOptionsProps<T extends string> = { items: SelectionOption<T>[]; query: string; disableLocalFilter?: boolean; filterFn?: (item: SelectionOption<T>, query: string) => boolean;};
/** * Filters a `SelectionOption` list against a search query. * * Returns the full list when `disableLocalFilter` is `true` or `query` is blank. * Applies `filterFn` when provided; otherwise uses a case-insensitive substring * match on `label`. */export function useFilteredOptions<T extends string>({ items, query, disableLocalFilter = false, filterFn,}: UseFilteredOptionsProps<T>) { return useMemo(() => { if (disableLocalFilter || !query.trim()) { return items; } if (filterFn) { return items.filter((item) => filterFn(item, query)); } const normalizedQuery = query.toLowerCase(); return items.filter((item) => item.label.toLowerCase().includes(normalizedQuery), ); }, [items, query, disableLocalFilter, filterFn]);}Update the import paths to match your project setup.
import { MultiSelect } from "@/components/ui/multi-select";import type { SelectionOption } from "@/hooks/use-filtered-options";const options: SelectionOption<string>[] = [ { value: "react", label: "React" }, { value: "typescript", label: "TypeScript" },];
<MultiSelect items={options} value={selected} onValueChange={setSelected} placeholder="Select skills..."/>;Each option is { value: T, label: string, disabled?: boolean }. The generic T extends string so values stay fully typed. value is always the complete current selection array — not a delta.
Examples
Section titled “Examples”Count Badge and Clear All
Section titled “Count Badge and Clear All”Use endAddon to show a live count of selected items alongside a clear-all button.
import { useState } from "react";import { X } from "lucide-react";import { MultiSelect } from "@/components/ui/multi-select"";import type { SelectionOption } from "@/registry/base/use-filtered-options/hooks/use-filtered-options";
type Dept = | "design" | "engineering" | "product" | "marketing" | "sales" | "support" | "finance" | "legal";
const departments: SelectionOption<Dept>[] = [ { value: "design", label: "Design" }, { value: "engineering", label: "Engineering" }, { value: "product", label: "Product" }, { value: "marketing", label: "Marketing" }, { value: "sales", label: "Sales" }, { value: "support", label: "Support" }, { value: "finance", label: "Finance" }, { value: "legal", label: "Legal" },];
export function MultiSelectWithCount() { const [selected, setSelected] = useState<Dept[]>([]);
return ( <div className="w-72"> <MultiSelect items={departments} value={selected} onValueChange={setSelected} placeholder="Filter by department..." endAddon={ selected.length > 0 && ( <div className="flex items-center gap-1"> <span className="rounded bg-primary px-1.5 py-0.5 text-[11px] font-semibold leading-none text-primary-foreground"> {selected.length} </span> <button onMouseDown={(e) => { e.preventDefault(); setSelected([]); }} className="flex items-center justify-center rounded p-0.5 hover:bg-muted" > <X className="size-3 text-muted-foreground" /> </button> </div> ) } /> </div> );}Async Search
Section titled “Async Search”Wire onQueryChange to a debounced fetch, set disableLocalFilter, and toggle isLoading during the request. The component renders a skeleton in place of the list until results land.
No members added yet.
import { useState } from "react";import { MultiSelect } from "@/components/ui/multi-select"";import { useDebounce } from "@/registry/base/use-debounce/hooks/use-debounce";import type { SelectionOption } from "@/registry/base/use-filtered-options/hooks/use-filtered-options";
type MemberId = string;
const allMembers: SelectionOption<MemberId>[] = [ { value: "m1", label: "Alice Johnson" }, { value: "m2", label: "Bob Smith" }, { value: "m3", label: "Carol White" }, { value: "m4", label: "David Brown" }, { value: "m5", label: "Eve Davis" }, { value: "m6", label: "Frank Miller" }, { value: "m7", label: "Grace Wilson" }, { value: "m8", label: "Henry Moore" },];
function searchMembers(query: string): Promise<SelectionOption<MemberId>[]> { return new Promise((resolve) => setTimeout(() => { const q = query.toLowerCase(); resolve(allMembers.filter((m) => m.label.toLowerCase().includes(q))); }, 500), );}
export function AsyncSearchMultiSelect() { const [selected, setSelected] = useState<MemberId[]>([]); const [items, setItems] = useState(allMembers); const [isLoading, setIsLoading] = useState(false);
const { execute: onQueryChange } = useDebounce(async (query: string) => { if (!query.trim()) { setItems(allMembers); setIsLoading(false); return; } setIsLoading(true); const results = await searchMembers(query); setItems(results); setIsLoading(false); }, 400);
return ( <div className="flex w-72 flex-col gap-3"> <MultiSelect items={items} value={selected} onValueChange={setSelected} onQueryChange={onQueryChange} disableLocalFilter isLoading={isLoading} placeholder="Add team members..." emptyContent="No members found." /> <p className="text-sm text-muted-foreground"> {selected.length > 0 ? `${selected.length} member${selected.length > 1 ? "s" : ""} added` : "No members added yet."} </p> </div> );}Custom Option Rendering
Section titled “Custom Option Rendering”renderOption gives full control over each list item. Use it to add colored indicators, avatars, or secondary metadata. The selected pills below are built from the same data — no separate state needed.
import { useState } from "react";import { Check } from "lucide-react";import { cn } from "@/lib/utils";import { MultiSelect } from "@/components/ui/multi-select"";import type { SelectionOption } from "@/registry/base/use-filtered-options/hooks/use-filtered-options";
type Status = | "backlog" | "todo" | "in-progress" | "in-review" | "done" | "cancelled";
type StatusOption = SelectionOption<Status> & { dot: string; pill: string };
const statuses: StatusOption[] = [ { value: "backlog", label: "Backlog", dot: "bg-muted-foreground", pill: "bg-muted text-muted-foreground", }, { value: "todo", label: "To Do", dot: "bg-blue-500", pill: "bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-300", }, { value: "in-progress", label: "In Progress", dot: "bg-yellow-500", pill: "bg-yellow-100 text-yellow-700 dark:bg-yellow-950 dark:text-yellow-300", }, { value: "in-review", label: "In Review", dot: "bg-purple-500", pill: "bg-purple-100 text-purple-700 dark:bg-purple-950 dark:text-purple-300", }, { value: "done", label: "Done", dot: "bg-green-500", pill: "bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-300", }, { value: "cancelled", label: "Cancelled", dot: "bg-red-400", pill: "bg-red-100 text-red-600 dark:bg-red-950 dark:text-red-300", },];
const statusMap = new Map(statuses.map((s) => [s.value, s]));
export function CustomRenderMultiSelect() { const [selected, setSelected] = useState<Status[]>( statuses.slice(0, 3).map((status) => status.value), );
return ( <div className="flex w-80 flex-col gap-3"> <MultiSelect items={statuses} value={selected} onValueChange={setSelected} placeholder="Filter by status..." renderOption={(option, isSelected) => { const s = statusMap.get(option.value)!; return ( <div className="flex w-full items-center gap-2"> <span className={cn("size-2 shrink-0 rounded-full", s.dot)} /> <span className="flex-1 text-sm">{s.label}</span> {isSelected && <Check className="ml-auto size-3.5" />} </div> ); }} /> {selected.length > 0 && ( <div className="flex flex-wrap gap-1.5"> {selected.map((v) => { const s = statusMap.get(v)!; return ( <span key={v} className={cn( "inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium", s.pill, )} > <span className={cn("size-1.5 rounded-full", s.dot)} /> {s.label} </span> ); })} </div> )} </div> );}Create New Option
Section titled “Create New Option”Use emptyContent with a controlled query to surface an inline “Add” action when nothing matches. onMouseDown on the button prevents the input blur that would otherwise close the dropdown before the click fires.
No skills selected.
import { useState } from "react";import { PlusCircle } from "lucide-react";
import { MultiSelect } from "@/components/ui/multi-select"";import type { SelectionOption } from "@/registry/base/use-filtered-options/hooks/use-filtered-options";
const initialSkills: SelectionOption<string>[] = [ { value: "react", label: "React" }, { value: "typescript", label: "TypeScript" }, { value: "nodejs", label: "Node.js" }, { value: "python", label: "Python" }, { value: "docker", label: "Docker" },];
export function CreatableMultiSelect() { const [skills, setSkills] = useState(initialSkills); const [selected, setSelected] = useState<string[]>([]); const [query, setQuery] = useState("");
const trimmed = query.trim(); const showAdd = trimmed.length > 0 && !skills.some((s) => s.label.toLowerCase().includes(trimmed.toLowerCase()));
const skillMap = new Map(skills.map((s) => [s.value, s]));
const handleAdd = () => { const label = trimmed; const value = label.toLowerCase().replace(/\s+/g, "-"); const newSkill: SelectionOption<string> = { value, label }; setSkills((prev) => [...prev, newSkill]); setSelected((prev) => [...prev, value]); setQuery(""); };
return ( <div className="flex w-72 flex-col gap-3"> <MultiSelect items={skills} value={selected} onValueChange={setSelected} query={query} onQueryChange={setQuery} placeholder="Search or add a skill..." emptyContent={ showAdd ? ( <button className="inline-flex cursor-pointer items-center gap-1.5 font-medium text-foreground hover:underline" onMouseDown={(e) => e.preventDefault()} onClick={handleAdd} > <PlusCircle className="size-3.5" /> {`Add "${trimmed}"`} </button> ) : undefined } /> {selected.length > 0 ? ( <div className="flex flex-wrap gap-1.5"> {selected.map((v) => ( <span key={v} className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground" > {skillMap.get(v)?.label} </span> ))} </div> ) : ( <p className="text-sm text-muted-foreground">No skills selected.</p> )} </div> );}| Prop | Type | Default | Description |
|---|---|---|---|
value | T[] | — | Required. Controlled array of currently selected values |
onValueChange | (value: T[]) => void | — | Required. Called with the full updated array after each toggle |
items | SelectionOption<T>[] | — | Required. Full list of options to display |
open | boolean | — | Controlled open state. Omit to let the component manage it |
onOpenChange | (open: boolean) => void | — | Called whenever the dropdown opens or closes |
query | string | — | Controlled search query. Omit for uncontrolled |
onQueryChange | (query: string) => void | — | Called whenever the search input changes |
placeholder | string | "Search..." | Placeholder text shown in the search input |
clearQueryOnSelect | boolean | true | Clear the search query after each selection toggle |
closeAfterSelect | boolean | false | Close the dropdown immediately after each selection toggle |
disableLocalFilter | boolean | false | Skip client-side filtering. Use when items are already filtered server-side |
filterFn | (item: SelectionOption<T>, query: string) => boolean | — | Custom filter predicate. Replaces the default case-insensitive label match |
isLoading | boolean | — | Show the loading state in place of the option list |
emptyContent | ReactNode | "No results found" | Content shown when no options match the query |
loadingContent | ReactNode | Skeleton bar | Content shown in place of the list while isLoading is true |
renderOption | (option: SelectionOption<T>, selected: boolean) => ReactNode | — | Custom renderer for each list item |
startAddon | ReactNode | — | Leading addon rendered inside the input (e.g. a search icon) |
endAddon | ReactNode | — | Trailing addon rendered inside the input (e.g. a count badge or clear button) |
All PopoverContent props (align, side, sideOffset, etc.) are also accepted and forwarded to the dropdown.