MultiCombobox
Searchable multi-select input. Composes Popover + Command (cmdk) + a styled trigger. Trigger shows '{n} selected'; each option exposes aria-selected.
Installation
pnpm dlx @hex-core/cli add multi-comboboxUsage
import { MultiCombobox } from "@/components/ui/multi-combobox"Tag picker
Multi-select with a small static list and chip count
import { useState } from "react";
import { MultiCombobox } from "@/components/ui/multi-combobox";
const tags = [
{ value: "bug", label: "Bug" },
{ value: "feature", label: "Feature" },
{ value: "question", label: "Question" },
{ value: "docs", label: "Documentation" },
];
export function Example() {
const [picks, setPicks] = useState<string[]>([]);
return (
<MultiCombobox
options={tags}
value={picks}
onChange={setPicks}
placeholder="Pick tags"
aria-label="Tags"
/>
);
}Capped selection
Limit the number of items the user can pick
import { useState } from "react";
import { MultiCombobox } from "@/components/ui/multi-combobox";
export function Example() {
const [picks, setPicks] = useState<string[]>([]);
return (
<MultiCombobox
options={tags}
value={picks}
onChange={setPicks}
maxSelected={3}
aria-label="Up to 3 tags"
/>
);
}API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
optionsrequired | object | — | Array of { value: string, label: string, disabled?: boolean } |
value | object | — | Controlled selected values (string[]) |
onChange | function | — | Callback when the user toggles an option: (values: string[]) => void |
placeholder | string | Select… | Text shown on the trigger when nothing is selected |
searchPlaceholder | string | Search… | Placeholder for the filter input |
emptyText | string | No results found. | Shown inside the list when the search has no matches |
maxSelected | number | — | Soft cap on selections — once reached, unselected options become aria-disabled and clicks are ignored |
closeOnSelect | boolean | false | Close the popover after every pick. Default false matches multi-select UX (Linear/Notion). |
disabled | boolean | false | Disable the trigger |
aria-label | string | — | Accessible label — required when no adjacent visible label is used |
aria-labelledby | string | — | Id of an external visible label that names the combobox |
AI Guidance
When to use
Use to pick multiple items from a list of >~8 options where users benefit from typing to narrow. Common for tags, recipients, filters. Trigger shows count, each option exposes aria-selected.
When not to use
Don't use for single-select (use Combobox). Don't use for free-text entry (use Input or a tag input). Don't use for very large lists (>500 options) without server-side filtering — cmdk filters in-memory.
Common mistakes
- Passing duplicate option values — Set-based selection treats them as one
- Two options with identical labels — cmdk dedupes by the Item's filter value (the label here), so one will be dropped from the list
- Forgetting that value is string[] not string — passing a single string breaks Array iteration
- Setting closeOnSelect={true} for a power-user picker — multi-select normally stays open until the user dismisses
- Missing aria-label / aria-labelledby — role='combobox' does not derive its name from contents, so the trigger has no accessible name without one
- Relying on maxSelected to enforce business rules — the cap is a UX hint; always validate the array length on submit
Accessibility
Trigger has role='combobox' + aria-expanded + aria-haspopup='listbox'. aria-controls points at the inner CommandList only when open. Each option carries aria-selected; capped/disabled options carry aria-disabled. A visually-hidden aria-live='polite' region inside the trigger announces selection-count changes.
Token budget: 1100