All componentsform
Multiselect
Searchable, chip-based multi-select combobox. Try arrow keys + Enter, and Backspace on an empty search to remove the last chip.
Default
ReactTypeScript
Selected: react, typescript
Max selection (2)
Create-new
Type a label that doesn't exist yet and pick "Create" to add it.
Async options
Options are fetched (simulated, 500ms) on every search change.
Installation
$ npx shadcn@latest add https://deepmoon.dev/r/multiselect.jsonnpm dependencies: lucide-react
Props
MultiselectProps
| Prop | Type | Required |
|---|---|---|
| options | MultiselectOption[] | Yes |
| value | string[] | Yes |
| onChange | (value: string[]) => void | Yes |
| onSearchChange | (search: string) => void | No |
| loading | boolean | No |
| placeholder | string | No |
| maxSelected | number | No |
| creatable | boolean | No |
| onCreateOption | (label: string) => void | No |
| disabled | boolean | No |
| className | string | No |
| aria-label | string | No |
Code
multiselect.tsx
"use client";
import * as React from "react";
import { ChevronDown, Loader2, Plus, X } from "lucide-react";
import { cn } from "@/lib/utils";
export interface MultiselectOption {
value: string;
label: string;
disabled?: boolean;
}
export interface MultiselectProps {
options: MultiselectOption[];
value: string[];
onChange: (value: string[]) => void;
/** Fires on every keystroke — debounce/fetch in the caller for async options. */
onSearchChange?: (search: string) => void;
loading?: boolean;
placeholder?: string;
maxSelected?: number;
creatable?: boolean;
onCreateOption?: (label: string) => void;
disabled?: boolean;
className?: string;
"aria-label"?: string;
}
const CREATE_VALUE = "__deepmoon_create__";
/**
* WAI-ARIA "combobox with listbox popup" pattern, adapted for multi-select:
* focus stays on the text input the whole time; ArrowUp/Down move
* aria-activedescendant across the popup's role="option" items (never real
* DOM focus); Enter activates whichever option aria-activedescendant
* points at. Selected options are removed from the popup and rendered as
* chips instead, so aria-selected marks "the option Enter would pick"
* rather than "already chosen" — aria-multiselectable is intentionally
* omitted since this listbox never shows more than one item as active at
* once, unlike a true simultaneous-multi-select listbox.
*/
export function Multiselect({
options,
value,
onChange,
onSearchChange,
loading = false,
placeholder = "Select...",
maxSelected,
creatable = false,
onCreateOption,
disabled = false,
className,
"aria-label": ariaLabel = "Select options",
}: MultiselectProps) {
const [search, setSearch] = React.useState("");
const [open, setOpen] = React.useState(false);
const [activeIndex, setActiveIndex] = React.useState(-1);
const [announcement, setAnnouncement] = React.useState("");
const rootRef = React.useRef<HTMLDivElement>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const baseId = React.useId();
const listboxId = `${baseId}-listbox`;
const selectedOptions = options.filter((o) => value.includes(o.value));
const atMax = maxSelected !== undefined && value.length >= maxSelected;
const rawVisibleOptions = options.filter(
(o) => !value.includes(o.value) && o.label.toLowerCase().includes(search.trim().toLowerCase()),
);
const exactMatch = options.some((o) => o.label.toLowerCase() === search.trim().toLowerCase());
const showCreate = creatable && search.trim() !== "" && !exactMatch;
type Item = MultiselectOption & { create?: boolean };
const items: Item[] = atMax
? []
: showCreate
? [...rawVisibleOptions, { value: CREATE_VALUE, label: search.trim(), create: true }]
: rawVisibleOptions;
React.useEffect(() => {
function handlePointerDown(event: PointerEvent) {
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
setOpen(false);
}
}
document.addEventListener("pointerdown", handlePointerDown);
return () => document.removeEventListener("pointerdown", handlePointerDown);
}, []);
React.useEffect(() => {
setActiveIndex(items.length > 0 ? 0 : -1);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [search, options.length, value.length, atMax]);
const selectOption = (option: Item) => {
if (option.disabled) return;
if (option.create) {
onCreateOption?.(option.label);
setAnnouncement(`${option.label} created and added.`);
} else {
onChange([...value, option.value]);
setAnnouncement(`${option.label} added.`);
}
setSearch("");
onSearchChange?.("");
inputRef.current?.focus();
};
const removeValue = (optionValue: string) => {
const removed = options.find((o) => o.value === optionValue);
onChange(value.filter((v) => v !== optionValue));
if (removed) setAnnouncement(`${removed.label} removed.`);
inputRef.current?.focus();
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (disabled) return;
if (event.key === "ArrowDown") {
event.preventDefault();
setOpen(true);
setActiveIndex((i) => (items.length === 0 ? -1 : (i + 1) % items.length));
} else if (event.key === "ArrowUp") {
event.preventDefault();
setOpen(true);
setActiveIndex((i) => (items.length === 0 ? -1 : (i - 1 + items.length) % items.length));
} else if (event.key === "Enter") {
event.preventDefault();
if (open && activeIndex >= 0 && items[activeIndex]) {
selectOption(items[activeIndex]);
}
} else if (event.key === "Escape") {
setOpen(false);
} else if (event.key === "Backspace" && search === "" && value.length > 0) {
removeValue(value[value.length - 1]);
}
};
const activeOption = activeIndex >= 0 ? items[activeIndex] : undefined;
return (
<div
ref={rootRef}
className={cn("relative w-full", className)}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) setOpen(false);
}}
>
<div
onClick={() => {
if (!disabled) {
inputRef.current?.focus();
setOpen(true);
}
}}
className={cn(
"flex min-h-9 w-full flex-wrap items-center gap-1.5 rounded-md border border-input bg-background px-2 py-1.5",
"has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring",
disabled && "cursor-not-allowed opacity-50",
)}
>
{selectedOptions.map((option) => (
<span
key={option.value}
className="flex items-center gap-1 rounded bg-secondary py-0.5 pl-2 pr-1 text-xs font-medium text-secondary-foreground"
>
{option.label}
{!disabled ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
removeValue(option.value);
}}
aria-label={`Remove ${option.label}`}
className="rounded-sm p-0.5 text-secondary-foreground/70 outline-none hover:bg-secondary-foreground/10 hover:text-secondary-foreground focus-visible:ring-1 focus-visible:ring-ring"
>
<X className="size-3" aria-hidden="true" />
</button>
) : null}
</span>
))}
<input
ref={inputRef}
role="combobox"
aria-expanded={open}
aria-controls={listboxId}
aria-autocomplete="list"
aria-activedescendant={activeOption ? `${baseId}-option-${activeOption.value}` : undefined}
aria-label={ariaLabel}
disabled={disabled}
value={search}
onChange={(event) => {
setSearch(event.target.value);
onSearchChange?.(event.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onKeyDown={handleKeyDown}
placeholder={selectedOptions.length === 0 ? placeholder : ""}
className="min-w-16 flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground"
/>
{loading ? (
<Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" aria-hidden="true" />
) : (
<ChevronDown className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
)}
</div>
{open && !disabled ? (
<ul
id={listboxId}
role="listbox"
aria-label={ariaLabel}
className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md border border-border bg-popover p-1 shadow-md"
>
{atMax ? (
<li className="px-2 py-1.5 text-sm text-muted-foreground">Maximum of {maxSelected} selected.</li>
) : items.length === 0 ? (
<li className="px-2 py-1.5 text-sm text-muted-foreground">
{loading ? "Loading..." : "No options found."}
</li>
) : (
items.map((option, index) => (
<li
key={option.value}
id={`${baseId}-option-${option.value}`}
role="option"
aria-selected={index === activeIndex}
aria-disabled={option.disabled || undefined}
onMouseEnter={() => setActiveIndex(index)}
onMouseDown={(event) => {
event.preventDefault();
selectOption(option);
}}
className={cn(
"flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm",
index === activeIndex && "bg-accent text-accent-foreground",
option.disabled && "cursor-not-allowed opacity-50",
)}
>
{option.create ? (
<>
<Plus className="size-3.5 shrink-0" aria-hidden="true" />
Create "{option.label}"
</>
) : (
option.label
)}
</li>
))
)}
</ul>
) : null}
<span aria-live="polite" className="sr-only">
{announcement}
</span>
</div>
);
}