MultiCombobox
Searchable multi-select input. Composes Popover + Command (cmdk) + a styled trigger. Trigger shows '{n} selected'; each option exposes aria-selected.
Tag picker (searchable, no cap)
Capped selection — `maxSelected=2` blocks further picks
Disabled control — locked while a parent operation is in flight
Installation
pnpm dlx @hex-core/cli add multi-comboboxCapped 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"
/>
);
}With empty state callout
Customize the empty-results copy and pair the trigger with a labelled caption. Wire the visible Label via aria-labelledby (the trigger has no native id surface) so the combobox keeps an explicit accessible name.
import { useState } from "react";
import { MultiCombobox } from "@/components/ui/multi-combobox";
import { Label } from "@/components/ui/label";
const remainingReviewers = [
{ value: "alice", label: "Alice Park" },
{ value: "ben", label: "Ben Yu" },
{ value: "caro", label: "Carolina Vaz" },
];
export function ReviewerPicker() {
const [picks, setPicks] = useState<string[]>([]);
return (
<div className="space-y-1">
<Label id="reviewers-label">Add reviewers</Label>
<MultiCombobox
options={remainingReviewers}
value={picks}
onChange={setPicks}
placeholder="Select reviewers"
searchPlaceholder="Filter by name"
emptyText="No matching reviewers - try a different name"
aria-labelledby="reviewers-label"
/>
<p className="text-xs text-muted-foreground">
Showing teammates not yet picked. Already-assigned reviewers are hidden from the list.
</p>
</div>
);
}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
Verified against @hex-core/components@1.12.0