Combobox
A form field that filters a list of options as you type.
Installation
npx shadcn@latest add @fujin/comboboxUsage
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox"<Field name="framework">
<FieldLabel>Framework</FieldLabel>
<Combobox items={frameworks} value={value} onValueChange={setValue}>
<ComboboxInput placeholder="Search frameworks..." showClear />
<ComboboxContent>
<ComboboxEmpty>No framework found.</ComboboxEmpty>
<ComboboxList>
{(framework) => (
<ComboboxItem key={framework.value} value={framework}>
{framework.label}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</Field>Filtering runs against the items prop, not the rendered text:
ComboboxList's child function is called for each match. Items shaped
{ value, label } need no configuration - the input shows label and the
form submits value. For other shapes, pass itemToStringLabel and
itemToStringValue.
Select, combobox or command?
select- a short list (roughly under 15) where scanning is faster than typing. No text input.combobox- a longer list, in a form, where typing to narrow it helps. The value must be one of the options.command- a search-to-pick list embedded in other UI (data-table's filter bar, a command palette), not a labelled form field.- Free text with suggestions (the typed text is the value) - use Base UI's Autocomplete; a combobox discards text that doesn't match an option.
What happens to typed text
Typed text is only a filter. In single mode, when the popup closes the input
reverts to the selected item's label, or empties if nothing is selected. In
multiple mode the filter text clears after each pick and on close.
Multiple selection with chips
With no children, ComboboxChips renders a chip per selected item plus the
input:
<Combobox items={labels} multiple defaultValue={[labels[0]]}>
<ComboboxChips
aria-label="Selected labels"
placeholder="Add labels..."
limit={3}
showClear
/>
<ComboboxContent>
<ComboboxEmpty>No labels found.</ComboboxEmpty>
<ComboboxList>
{(item) => (
<ComboboxItem key={item.value} value={item}>
{item.label}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>From the start of the input, Left Arrow moves focus into the chips; Backspace or Delete removes the focused chip. The remove button is a 24px pointer target and is not in the tab order.
Handling many chips
- The box stops growing. Chips wrap up to
--combobox-chips-max-height(default8.5rem, about four rows), then the box scrolls. Set the variable onComboboxChipsto change it. limitcollapses the selection while focus is elsewhere: only the firstlimitchips show, followed by "+N more". Clicking or tabbing into the field shows them all again, so every chip stays reachable by keyboard.showClearadds a 24px "Clear all" button while anything is selected.
To render chips yourself (custom content, avatars), pass children. limit
then no longer applies:
<ComboboxChips aria-label="Selected labels">
<ComboboxValue>
{(selected) => (
<>
{selected.map((item) => (
<ComboboxChip key={item.value}>{item.label}</ComboboxChip>
))}
<ComboboxChipsInput placeholder={selected.length ? "" : "Add..."} />
</>
)}
</ComboboxValue>
</ComboboxChips>For custom chip content with the built-in layout, use renderChip:
<ComboboxChips renderChip={(user) => <><Avatar ... />{user.name}</>} />.
Creatable
Pass onCreate to let people add an option that isn't in the list. When the
typed text matches no item's label exactly, a Create "<text>" option
appears at the end of the list; picking it, or pressing Enter with nothing
highlighted, calls onCreate(text).
const [tags, setTags] = React.useState(initialTags)
<Combobox
items={tags}
multiple
onCreate={async (label) => {
const tag = await api.createTag(label)
setTags((current) => [...current, tag])
return tag
}}
>
<ComboboxChips aria-label="Selected tags" placeholder="Add tags..." />
<ComboboxContent>
<ComboboxEmpty>No tags found.</ComboboxEmpty>
<ComboboxList>
{(tag) => (
<ComboboxItem key={tag.value} value={tag}>
{tag.label}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>- Return the new item to select it, or a promise of it. While the promise is pending, the option shows a spinner and can't be picked again. Return nothing to cancel.
- Add it to
itemsyourself.onCreatedoesn't touch your list, so the item would vanish from it once deselected. - Handle errors inside
onCreate. Show a toast or a field error; a rejected promise selects nothing. - Exact matches aren't duplicated. Text that matches an existing label (case-insensitive, trimmed) selects that item instead.
- Your list renderer covers the create option.
ComboboxItemrenders the plus icon and label itself. - Flat
itemsonly. Grouped items get no create option.
It works the same in single mode. formatCreateLabel changes the option's
text:
<Combobox
items={companies}
value={company}
onValueChange={setCompany}
onCreate={(label) => {
const created = { value: crypto.randomUUID(), label }
setCompanies((current) => [...current, created])
return created
}}
formatCreateLabel={(label) => `Add "${label}" as a new company`}
>
<ComboboxInput placeholder="Search or add a company..." showClear />
...
</Combobox>The logic lives in lib/combobox-creatable.ts (useComboboxCreatable), so
you can use it on a bare Base UI Combobox too.
Groups
Pass groups as { value, items }[] and render each with a
ComboboxCollection:
<ComboboxList>
{(group) => (
<ComboboxGroup key={group.value} items={group.items}>
<ComboboxGroupLabel>{group.value}</ComboboxGroupLabel>
<ComboboxCollection>
{(item) => (
<ComboboxItem key={item.value} value={item}>
{item.label}
</ComboboxItem>
)}
</ComboboxCollection>
</ComboboxGroup>
)}
</ComboboxList>What's different from shadcn/ui
shadcn/ui builds a combobox by composing Popover and Command (cmdk). This
one is Base UI's Combobox: one component with a real input as the form
control, a hidden input for form submission, Field integration, and
filtering driven by items.
API Reference
Prop
Type
Every other prop is forwarded to the Base UI Combobox.