Combobox
A searchable list in a popover that chooses one value or several for a field.
Combobox chooses a value for a field from a list you search by typing, like an assignee, a project, or a set of labels. It is Command in a Popover under its trigger, and a check marks each chosen row. It holds one value or several, and the value itself lives in your state. For a short list that needs no search, use Select.
Usage
import { Field, FieldLabel } from '@/components/ui/field';
import {
Combobox,
ComboboxButton,
ComboboxContent,
ComboboxEmpty,
ComboboxGroup,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxTrigger,
} from '@/components/ui/combobox';
<Field>
<FieldLabel htmlFor="assignee">Assignee</FieldLabel>
<Combobox>
<ComboboxTrigger asChild>
<ComboboxButton id="assignee" placeholder="Assign…">
{assignee?.name}
</ComboboxButton>
</ComboboxTrigger>
<ComboboxContent>
<ComboboxInput aria-label="Search people" placeholder="Search people…" />
<ComboboxList>
<ComboboxEmpty>No one matches.</ComboboxEmpty>
<ComboboxGroup heading="People">
{people.map((person) => (
<ComboboxItem
key={person.id}
value={person.name}
selected={person.id === assignee?.id}
onSelect={() => setAssignee(person)}
>
{person.name}
</ComboboxItem>
))}
</ComboboxGroup>
</ComboboxList>
</ComboboxContent>
</Combobox>
</Field>The file exports the nine parts above. ComboboxTrigger is the Popover trigger, so it takes
asChild and the button inside it. The rest wrap Command's parts: ComboboxInput is
CommandInput, ComboboxList is CommandList, and ComboboxGroup, ComboboxItem, and
ComboboxEmpty are Command's group, item, and empty state.
The value is yours
Combobox owns only whether the popover is open and whether a pick closes it. You hold the
value, pass selected to each row that holds it, and set it in the row's onSelect. A
chosen row shows a check on the right, the way Select and
Dropdown menu mark the current value. With one
value, a pick runs onSelect, closes the popover, and returns focus to the trigger. Picking
the chosen row again runs onSelect again, so your handler decides whether that clears it.
Set value on every row. It is the text the search matches and what onSelect receives,
and when it is left out cmdk reads it from the row's text.
Choosing several
multiple keeps the list open on select, so picking three labels is one trip rather than
three. Hold an array, pass selected to each row in it, and toggle the row in onSelect.
The list is marked aria-multiselectable too.
<Combobox multiple>
<ComboboxTrigger asChild>
<ComboboxButton id="labels" placeholder="Add labels…">
{labels.join(', ')}
</ComboboxButton>
</ComboboxTrigger>
<ComboboxContent>
<ComboboxInput aria-label="Search labels" placeholder="Search labels…" />
<ComboboxList>
{LABELS.map((label) => (
<ComboboxItem
key={label}
value={label}
selected={labels.includes(label)}
onSelect={() => toggle(label)}
>
{label}
</ComboboxItem>
))}
<ComboboxItem value="Clear all" closeOnSelect onSelect={() => setLabels([])}>
Clear all
</ComboboxItem>
</ComboboxList>
</ComboboxContent>
</Combobox>closeOnSelect on a row overrides the root for that row. It is what a "Clear all" row wants
in an otherwise multi-select list, as in the demo, and in a single-select combobox
closeOnSelect={false} keeps a row from closing the popover.
To put the combobox behind a chip instead of a button, see Chip trigger, which also covers several values shown as badges.
The trigger
ComboboxButton is an outline Button at sm with a caret
on the right. It is full width and 36px tall, which is 4px shorter than an Input or a Select
trigger in the same form, and on a touch screen it grows to at least 44px. It shows its
children on one line, cut off when they run long, and its placeholder in muted text when
the children are empty. It takes Button's props, so size, variant, and disabled change
it.
Any button can be the trigger instead, through ComboboxTrigger asChild, which is how a chip
opens one.
The panel and the search
ComboboxContent opens 4px under the trigger, aligned to its start edge, as wide as the
trigger and at least 224px. It has no padding of its own, and the rows sit 4px in from its
edge whether or not you wrap them in a group, the same 4px a Dropdown menu row clears. Reach
for a group when the rows want a heading, not for spacing. The list scrolls past 256px.
cmdk filters as you type, as in Command. It matches each row's value and keywords, hides
the rows that do not match, and sorts the rest best first, and ComboboxEmpty shows when
nothing matches. In the first demo each person's team is a keyword, so "design" finds Maya
Chen.
ComboboxContent hands its props to the popover and not to cmdk, so shouldFilter,
filter, and loop cannot reach the list. For rows that come from a server, put a
Command with shouldFilter={false} in a Popover yourself, as
Command describes.
Props
Combobox takes Popover's root props, open, defaultOpen, onOpenChange, and modal,
plus multiple. Hold open yourself to open it from code, or set defaultOpen to open it
on mount and let it keep the state.
| Prop | Type | Default | What it does |
|---|---|---|---|
multiple | boolean | false | On Combobox. Keeps the popover open when a row is picked, for a value you hold as an array, and marks the list aria-multiselectable. |
selected | boolean | On ComboboxItem. Shows the check, and describes the row as "Selected" for screen readers. | |
closeOnSelect | boolean | Closes unless multiple | On ComboboxItem. true closes the popover when this row is picked, and false keeps it open. |
placeholder | string | On ComboboxButton. Shown in muted text when the button has no children. |
The rest take their parts' props. ComboboxContent takes PopoverContent's, with align
set to "start". ComboboxInput takes CommandInput's, including leading, trailing,
and wrapperClassName. ComboboxList takes cmdk's label, which names the list.
ComboboxGroup takes heading. ComboboxItem takes cmdk's value, keywords, disabled,
and onSelect, which gets the row's value. ComboboxButton takes Button's.
Accessibility
- The trigger is a button with the
comboboxrole,aria-haspopup="dialog", andaria-expanded. Point a FieldLabel'shtmlForat the button'sid, and the label names it and the button's text is its value, so the first demo reads as "Assignee" with the value "Maya Chen". Without a visible label, give the button anaria-label. - Everything in the button is read as the value, so mark decorative pieces like an avatar
aria-hidden, as the demos do, or its initials are read with the name. - Opening moves focus to the search field, which is a
comboboxthat controls alistboxofoptionrows and points at the highlighted row witharia-activedescendant. cmdk's hidden label is empty here, so the field'splaceholdernames it, and anaria-labelonComboboxInputnames it instead. The list is named "Suggestions" unless you passlabeltoComboboxList. - ↑ and ↓ move the highlight, Enter picks the highlighted row, and the rest of Command's keys work too. Opening highlights the first row, not the chosen one.
- cmdk sets
aria-selectedon the highlighted row rather than the chosen ones, which is right for one value and wrong for several. A chosen row is described as "Selected" througharia-describedbyinstead, so a screen reader hears the state without it joining the row's text, and the list isaria-multiselectablewhenmultipleis set. - Escape closes the popover and returns focus to the trigger, and so does a pick that closes it. A click outside closes it and leaves focus where you clicked. Tab does not leave the open popover, and the search field is its only stop, so Tab stays there.
- The popover is a
dialogwith no name, and it is not modal unless you setmodal, so the page behind stays available to assistive tech. ComboboxEmptyis not a live region, so a screen reader is not told when nothing matches.- The trigger is 36px tall and at least 44px on a touch screen. Rows are 32px and do not grow on a touch screen.
- Under reduced motion the popover opens and closes without its fade, and rows never animate.
- The highlight is only a background wash, which forced colors repaints, so in Windows high contrast the highlighted row has no mark of its own.
When to use something else
- Select for one value from a short list that needs no
search. Its trigger lines up with Input, and inside a
<form>its value submits, where Combobox adds nothing to the form. - Command to run an action rather than choose a value. Combobox is Command in a Popover that holds a chosen value for a field, where Command runs the highlighted row and holds nothing.
Install
shadcn add @gesso/combobox