Combobox
A filterable, keyboard-navigable listbox behind a text input — type-to-filter, grouped items, async status, and a multi-select chip mode.
- Status
- Since
0.1.0- Accessibility pattern
- APG combobox
Last updated
Install
Add Combobox from the VegaStack registry. The CLI verifies the item's integrity hash before writing it.
pnpm dlx shadcn@latest add @vegastack/comboboxThe same command installs the registry items it composes: @vegastack/chip, @vegastack/floating-surface.
Usage
import {
Combobox,
ComboboxInputGroup,
ComboboxInput,
ComboboxTrigger,
ComboboxContent,
ComboboxList,
ComboboxItem,
ComboboxEmpty,
} from "@/components/ui/combobox";
const fonts = ["Sans-serif", "Serif", "Monospace"];
<Combobox items={fonts}>
<ComboboxInputGroup className="w-64">
<ComboboxInput aria-label="Font family" placeholder="Search fonts…" />
<ComboboxTrigger aria-label="Toggle fonts" />
</ComboboxInputGroup>
<ComboboxContent>
<ComboboxEmpty>No fonts found.</ComboboxEmpty>
<ComboboxList>
{(item: string) => (
<ComboboxItem key={item} value={item}>
{item}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>;Pass items so the built-in Intl.Collator filter narrows the list as the user types, and so
ComboboxEmpty knows when nothing matches. Render ComboboxList's children as a function
({(item) => …}) — Base UI implicitly wraps it in Combobox.Collection and filters it live. Static
ComboboxItem children (like Select's) are not re-filtered against a typed query. Control the
selection with value + onValueChange, or leave it uncontrolled with defaultValue. Add multiple
to collect several values into an array and unlock the chip parts (see
Multiple selection).
Anatomy
Combobox is a compound component. Every exported part, with the
data-slot it renders (generated from the canonical source):
Examples
Anatomy
Combobox is a compound component built on
Base UI Combobox. Compose the parts inside the root:
<Combobox items={items}>
<ComboboxInputGroup>
<ComboboxInput />
<ComboboxClear aria-label="Clear" />
<ComboboxTrigger aria-label="Toggle" />
</ComboboxInputGroup>
<ComboboxContent>
<ComboboxEmpty>No results.</ComboboxEmpty>
<ComboboxList>
{(item) => (
<ComboboxItem key={item} value={item}>
{item}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>Combobox— the root; owns the selected value (value/defaultValue/onValueChange), the input text (inputValue/defaultInputValue/onInputValueChange), and the open state. Doesn't render an element. Non-modal by default (deliberately unlikeSelect/DropdownMenu) — see Modal usage.ComboboxInputGroup— the bordered field wrapper for the input and its adornments (data-slot="combobox-input-group",<div>). Reacts to focus/invalid/disabled directly via Base UI's owndata-*attributes.ComboboxInput— the text field that filters the list as the user types (data-slot="combobox-input",<input>). Styled likeInput, on the samesm/md/lgscale.ComboboxClear— an×button that clears the text (single mode) or the whole selection (multiple mode). Unmounts itself when there's nothing to clear.ComboboxTrigger— a chevron button that opens/closes the popup without moving focus off the input (data-slot="combobox-trigger",<button>). Default styling is a compact icon-only square, for the common case of sitting inside aComboboxInputGroup; overrideclassNamefor a full-width,Select-style button trigger paired withComboboxValueinstead of a visible input.ComboboxContent— the dropdown surface (data-slot="combobox-content"). Composes Base UI's Portal- Positioner + Popup and animates enter/exit — but, unlike
SelectContent, does not auto-wrap its children in a list (see below).
- Positioner + Popup and animates enter/exit — but, unlike
ComboboxList— the Base UI list wrapper required for keyboard navigation (data-slot="combobox-list",role="listbox"). Compose it explicitly as a sibling ofComboboxEmpty/ComboboxStatusinsideComboboxContent, not their parent —role="listbox"only permits option/group children per ARIA, and nestingComboboxEmpty(role="status") inside it fails thearia-required-childrena11y rule (verified incombobox.test.tsx).ComboboxItem— a single option (data-slot="combobox-item",role="option"). Shows a trailing check when selected; tints on keyboard/hover highlight, dims whendisabled.ComboboxEmpty— announced text shown when the query matches nothing (data-slot="combobox-empty",role="status"). Must stay mounted — Base UI toggles its children internally.ComboboxGroup/ComboboxGroupLabel/ComboboxCollection— grouped rendering; see Grouped items.ComboboxStatus— an announced status row for async state, e.g. a loading indicator (data-slot="combobox-status",role="status"). Must stay mounted likeComboboxEmpty.ComboboxValue— renders the selected value's label as read-only text; mainly for a button-styleComboboxTrigger, or (with a function child) to render chips in multiple mode.ComboboxChips/ComboboxChip/ComboboxChipRemove— themultiple-mode tag row; see Multiple selection.
Sizes
ComboboxInput takes a size prop: sm (h-7), md (h-8), and lg (h-10) — the same scale as
Input/Select, so a combobox lines up with sibling form controls.
Grouped items
Pass items as an array of groups ({ items, ...groupMeta }, e.g. { items, label }) and render one
ComboboxGroup per group with a ComboboxCollection function child. ComboboxGroup's own items
prop is used verbatim by its nested ComboboxCollection — it is not automatically re-filtered against
the typed query. Read each group's already-filtered items from the useComboboxFilteredItems() hook
instead of your original static array, or typing won't narrow the groups (verified —
combobox.test.tsx's "grouped rendering" test covers exactly this):
import { useComboboxFilteredItems } from "@/components/ui/combobox";
function GroupedItems() {
const groups = useComboboxFilteredItems<{ label: string; items: string[] }>();
return groups.map((group) => (
<ComboboxGroup key={group.label} items={group.items}>
<ComboboxGroupLabel>{group.label}</ComboboxGroupLabel>
<ComboboxCollection>
{(item: string) => (
<ComboboxItem key={item} value={item}>
{item}
</ComboboxItem>
)}
</ComboboxCollection>
</ComboboxGroup>
));
}
<ComboboxContent>
<ComboboxEmpty>No results.</ComboboxEmpty>
<ComboboxList>
<GroupedItems />
</ComboboxList>
</ComboboxContent>;Async / loading state
For server-driven search, control inputValue (via onInputValueChange) and pass the resolved
results as filteredItems — Base UI's escape hatch that bypasses its own built-in filter, so what's
rendered always matches what the "server" returned. Render ComboboxStatus with a Spinner while a
request is in flight:
<Combobox
items={fruits}
filteredItems={results}
inputValue={inputValue}
onInputValueChange={(value) => {
setInputValue(value);
search(value); // debounced; sets `results` + `loading`
}}
>
<ComboboxInputGroup>
<ComboboxInput aria-label="Search fruit" />
<ComboboxTrigger aria-label="Toggle" />
</ComboboxInputGroup>
<ComboboxContent>
<ComboboxStatus>
{loading ? (
<>
<Spinner size="inherit" label="" />
Searching…
</>
) : null}
</ComboboxStatus>
<ComboboxEmpty>{loading ? null : "No fruit found."}</ComboboxEmpty>
<ComboboxList>
{loading
? null
: results.map((item) => (
<ComboboxItem key={item} value={item}>
{item}
</ComboboxItem>
))}
</ComboboxList>
</ComboboxContent>
</Combobox>Multiple selection
Set multiple on the root to collect several values into an array. Compose ComboboxChips (wraps the
chip row and the input together — put the input last so it keeps filtering) with ComboboxValue's
function child rendering one ComboboxChip + ComboboxChipRemove per selected value:
<Combobox multiple items={labelKeys} defaultValue={["bug", "docs"]}>
<ComboboxInputGroup>
<ComboboxChips>
<ComboboxValue>
{(value: string[]) =>
value.map((key) => (
<ComboboxChip key={key}>
{labelFor(key)}
<ComboboxChipRemove aria-label={`Remove ${labelFor(key)}`} />
</ComboboxChip>
))
}
</ComboboxValue>
<ComboboxInput aria-label="Labels" placeholder="Add labels…" />
</ComboboxChips>
<ComboboxClear aria-label="Clear all labels" />
<ComboboxTrigger aria-label="Toggle labels" />
</ComboboxInputGroup>
<ComboboxContent>{/* ComboboxList / ComboboxItem */}</ComboboxContent>
</Combobox>While the popup is open, Base UI marks the chip row
aria-hidden(so a screen reader mid-selection stays on the listbox, not pulled back to the value summary) — close the popup before reaching aComboboxChipRemovevia assistive tech or an accessibility-tree-based test query. Sighted pointer users can still click it either way.
ComboboxChip and ComboboxChipRemove are the Chip primitive and its
remove control, composed onto Base UI's own Combobox.Chip / Combobox.ChipRemove through render
— so a selected value here is geometrically the same object as a Tag or a filter chip, and its
remove control is a real 24×24 target. (It previously shipped as a bare 16px box with no hit-area
expansion at all: a WCAG 2.5.8 failure, now covered by the contract lane.)
States
A disabled root makes the whole field (input + trigger + clear) inert.
Invalid
Pass aria-invalid to ComboboxInput — or wrap the Combobox in a <Field error>, which wires it
for you — to tint the field border destructive. Pair it with a FieldError so the failure is
announced, not just color-coded.
Modal usage
Combobox defaults modal to false (Base UI's own default), unlike Select/DropdownMenu. This is
deliberate: a combobox stays interactive alongside its open popup — typing keeps filtering, and in
multiple mode the ComboboxChipRemove controls next to the input must stay clickable. Setting
modal locks the rest of the page as inert behind a backdrop that only carves out the anchor's
original bounding box; once ComboboxChips grows the input group (wrapped chips), controls outside
that stale clip fall behind the inert backdrop and become unclickable — verified while building this
component. Reach for modal only for a single-select, button-trigger-style usage that should behave
like Select.
Playground
Try every size and the disabled state on a filterable list, then copy the generated JSX.
const fonts = ["Sans-serif", "Serif", "Monospace", "Cursive", "Fantasy"];
<Combobox items={fonts}>
<ComboboxInputGroup className="w-64">
<ComboboxInput aria-label="Font family" placeholder="Search fonts…" />
<ComboboxClear aria-label="Clear" />
<ComboboxTrigger aria-label="Toggle fonts" />
</ComboboxInputGroup>
<ComboboxContent>
<ComboboxEmpty>No fonts found.</ComboboxEmpty>
<ComboboxList>
{(item: string) => (
<ComboboxItem key={item} value={item}>
{item}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>API Reference
Combobox
Combobox adds no props of its own — it accepts everything the underlying element or Base UI primitive accepts (className, ref, ARIA attributes, event handlers).
Data attributes and CSS variables on Combobox
| Attribute | Values |
|---|---|
data-slot | "combobox" |
ComboboxInputGroup
| Prop | Type | Default | Description |
|---|---|---|---|
size | "lg" | "md" | "sm" | — |
Data attributes and CSS variables on ComboboxInputGroup
| Attribute | Values |
|---|---|
data-field-group | "" |
data-size | mirrors a prop or state value |
data-slot | "combobox-input-group" |
ComboboxInput
| Prop | Type | Default | Description |
|---|---|---|---|
size | "lg" | "md" | "sm" | — |
Data attributes and CSS variables on ComboboxInput
| Attribute | Values |
|---|---|
data-size | mirrors a prop or state value |
data-slot | "combobox-input" |
ComboboxPopupInput
ComboboxPopupInput adds no props of its own — it accepts everything the underlying element or Base UI primitive accepts (className, ref, ARIA attributes, event handlers).
Data attributes and CSS variables on ComboboxPopupInput
| Attribute | Values |
|---|---|
data-slot | "combobox-popup-input" | "combobox-popup-input-wrapper" |
Use ComboboxPopupInput when the searchable input belongs inside ComboboxContent, as in the
country and region selectors. It deliberately omits the outer trigger-input sizing contract.
ComboboxTrigger
| Prop | Type | Default | Description |
|---|---|---|---|
size | "lg" | "md" | "sm" | — |
Data attributes and CSS variables on ComboboxTrigger
| Attribute | Values |
|---|---|
data-size | mirrors a prop or state value |
data-slot | "combobox-icon" | "combobox-trigger" |
ComboboxClear
ComboboxClear adds no props of its own — it accepts everything the underlying element or Base UI primitive accepts (className, ref, ARIA attributes, event handlers).
Data attributes and CSS variables on ComboboxClear
| Attribute | Values |
|---|---|
data-slot | "combobox-clear" |
ComboboxContent
| Prop | Type | Default | Description |
|---|---|---|---|
align | Align | 'start' | Alignment relative to the anchor. |
collisionPadding | Padding | FLOATING.collisionPadding (8) | Padding (px) reserved around the popup during collision detection. |
portalProps | (Omit<ComboboxPortalProps, "ref"> & React.RefAttributes<HTMLDivElement>) | — | Props forwarded to the Base UI Combobox.Portal. |
positionerProps | (Omit<ComboboxPositionerProps, "ref"> & React.RefAttributes<HTMLDivElement>) | — | Props forwarded to the Base UI Combobox.Positioner. |
side | Side | 'bottom' | Preferred side of the anchor to render against. |
sideOffset | number | FLOATING.sideOffsetAttached (4) | Gap in px between the anchor and the popup. |
ComboboxList
ComboboxList adds no props of its own — it accepts everything the underlying element or Base UI primitive accepts (className, ref, ARIA attributes, event handlers).
Data attributes and CSS variables on ComboboxList
| Attribute | Values |
|---|---|
data-slot | "combobox-list" |
ComboboxItem
ComboboxItem adds no props of its own — it accepts everything the underlying element or Base UI primitive accepts (className, ref, ARIA attributes, event handlers).
Data attributes and CSS variables on ComboboxItem
| Attribute | Values |
|---|---|
data-slot | "combobox-item" | "combobox-item-indicator" |
ComboboxEmpty
ComboboxEmpty adds no props of its own — it accepts everything the underlying element or Base UI primitive accepts (className, ref, ARIA attributes, event handlers).
Data attributes and CSS variables on ComboboxEmpty
| Attribute | Values |
|---|---|
data-slot | "combobox-empty" |
ComboboxStatus
ComboboxStatus adds no props of its own — it accepts everything the underlying element or Base UI primitive accepts (className, ref, ARIA attributes, event handlers).
Data attributes and CSS variables on ComboboxStatus
| Attribute | Values |
|---|---|
data-slot | "combobox-status" |
ComboboxGroup
ComboboxGroup adds no props of its own — it accepts everything the underlying element or Base UI primitive accepts (className, ref, ARIA attributes, event handlers).
Data attributes and CSS variables on ComboboxGroup
| Attribute | Values |
|---|---|
data-slot | "combobox-group" |
ComboboxGroupLabel
ComboboxGroupLabel adds no props of its own — it accepts everything the underlying element or Base UI primitive accepts (className, ref, ARIA attributes, event handlers).
Data attributes and CSS variables on ComboboxGroupLabel
| Attribute | Values |
|---|---|
data-slot | "combobox-group-label" |
ComboboxValue
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Additional class names for the wrapping <span> (Base UI's Value renders no element). |
Data attributes and CSS variables on ComboboxValue
| Attribute | Values |
|---|---|
data-slot | "combobox-value" |
ComboboxChips
ComboboxChips adds no props of its own — it accepts everything the underlying element or Base UI primitive accepts (className, ref, ARIA attributes, event handlers).
Data attributes and CSS variables on ComboboxChips
| Attribute | Values |
|---|---|
data-slot | "combobox-chips" |
ComboboxChip
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Classes merged with the shared chip geometry. |
Data attributes and CSS variables on ComboboxChip
| Attribute | Values |
|---|---|
data-slot | "combobox-chip" |
ComboboxChipRemove
| Prop | Type | Default | Description |
|---|---|---|---|
aria-label* | string | — | Accessible name announced to assistive tech — required, the × has no visible text. |
className | string | — | Classes merged with the shared remove-control geometry. |
Data attributes and CSS variables on ComboboxChipRemove
| Attribute | Values |
|---|---|
data-slot | "combobox-chip-remove" |
Accessibility
ComboboxInputexposesrole="combobox"witharia-expanded/aria-haspopup="listbox"/aria-autocomplete="list"; the popup is arole="listbox"ofrole="option"items — no manual ARIA wiring needed.- Selection is announced via
aria-selected; the selected item shows a check indicator. Disabled items carryaria-disabledand are skipped by keyboard navigation. ComboboxEmpty/ComboboxStatusarerole="status"witharia-live="polite"— query-miss and async-loading feedback is announced without moving focus.- Full keyboard support: open/close, arrow navigation between items (looping back to the input), and
Escto dismiss and return focus to the input. Inmultiplemode,Backspaceon an empty query removes the last chip. - On
:focusthe field re-colors its border with theringtoken, matchingInput/Select.aria-invalidtints it destructive for use inside aField.
| Key | Action |
|---|---|
| ↓ / ↑ | Open the popup (if closed) and move the highlight between items. |
| Enter | Select the highlighted item and close the popup. |
| Esc | Close the popup without changing the selection; focus returns to the input. |
| Backspace | (Multiple, empty query) Highlight, then remove, the last chip. |
| ← / → | (Multiple) Move between chips when the query is empty at either edge. |
| Contract | States tested |
|---|---|
| Behaviour | default, disabled, empty, error, filtering, invalid, loading, open, read-only, selected |
| Accessibility | labeled, status-announcement, semantic-html |
| Visual | default, hover, focus, disabled, invalid, loading, error, empty, dark |
Do / Don't
Select
A dropdown for choosing one option — trigger with value and chevron, grouped and scrollable popup, full keyboard navigation, and animated enter/exit.
Searchable Select
The Select-shaped Combobox preset — a full-width trigger, an in-panel search field, a check on the selected row, and an optional clear control.