Combobox
A filterable, keyboard-navigable listbox behind a text input — type-to-filter, grouped items, an announced empty state, and a multi-select chips mode.
- Status
- Since
0.1.0- Accessibility pattern
- APG combobox
Last updated
Install
Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Combobox before installation. Retain the digest printed by this command.
pnpm exec vegastack-design verify comboboxInstall with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.
pnpm dlx shadcn@4.21.0 add @vegastack/comboboxRun the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.
The same command installs the registry items it composes: @vegastack/button, @vegastack/input-group, @vegastack/item, @vegastack/spinner.
Usage
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
<Combobox items={frameworks}>
<ComboboxInput placeholder="Select a framework" />
<ComboboxContent>
<ComboboxEmpty>No items found.</ComboboxEmpty>
<ComboboxList>
{(item) => (
<ComboboxItem key={item} value={item}>
{item}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>;Anatomy
Examples
Composition
Combobox
├── ComboboxInput
└── ComboboxContent
├── ComboboxEmpty
└── ComboboxList
└── ComboboxItemCustom Items
itemToStringValue tells the filter how to read an object item — without it, the filter has no
string to match a keystroke against.
Multiple Selection
multiple plus ComboboxChips turns the field into a chip editor.
Basic
A flat list of strings.
Multiple
useComboboxAnchor anchors the popup to the chips container rather than the inner input, so the
list stays put as chips wrap onto a second row.
Clear Button
showClear on ComboboxInput swaps the trigger for a clear control once something is selected.
The × is muted until hovered, and it is a keyboard stop while the list is closed — Tab to it and
press Enter.
Clear all
showClear on ComboboxChips adds a clear-all × at the end of the chips field. It removes every
chip in one press, shows only while something is selected, and is named "Clear all" (clearLabel
changes it). Each chip keeps its own remove ×, and Backspace in the empty input still
removes the last chip.
Groups
ComboboxGroup, ComboboxLabel, ComboboxCollection and ComboboxSeparator organise the popup.
Custom Items
A row is whatever you put inside ComboboxItem — here an Item with a title and a description, so
each option carries a second line. Upstream documents this separately from itemToStringValue
above, because it is the other half: one teaches the filter to READ an object, this one decides how
the object is DRAWN.
Invalid
Put aria-invalid on the ComboboxInput and data-invalid on the Field. The destructive border
holds in every state: focus never replaces it with the darker focus border (FOC-14).
Disabled
disabled on ComboboxInput disables the control, its trigger and its clear button together.
Auto Highlight
autoHighlight highlights the first match as the filter narrows, so Enter picks it
without an arrow press.
Popup
Trigger the list from a button with ComboboxTrigger and render, and move ComboboxInput inside
ComboboxContent with showTrigger={false} so the search sits in the popup.
Input Group
ComboboxInput is an InputGroup, so an InputGroupAddon child lands on the same surface.
RTL
The trigger, the clear control and the item check indicator are all placed with logical properties, so a right-to-left document mirrors the whole control.
Two-line options
An option that needs a second line composes Item's parts: ItemContent › ItemTitle +
ItemDescription. ComboboxItem provides Item's description context, so the description's id lands
on the option's aria-describedby and a screen reader hears it as the option's description
(API-19). A one-line option links nothing.
Async status
When the list comes from a server, pass filter={null} so the engine stops filtering locally, and
render ComboboxStatus as a sibling of ComboboxList. It is Base UI's own polite status region:
keep it mounted and change its children — "Searching…", then the result count — rather than
rendering it conditionally. It is screen-reader-only by default; visible shows the message as a
muted row above the list.
When the fetch itself is in flight or fails, say so in the popup instead of showing an empty list:
ComboboxContent loading shows a spinner row, and error (with onRetry) shows the failure with a
Try again button.
<ComboboxContent loading={isPending} error={failed} onRetry={refetch}>
<ComboboxList>…</ComboboxList>
</ComboboxContent>States
The closed input's rest, invalid and disabled chrome. Click or Tab into the field to see the focus
state: the field's border darkens subtly (border-ring/50) over 150ms and nothing fills; the invalid
field keeps its destructive border while focused.
Playground
Try the clear button with the disabled and invalid states on a filterable list, then copy the generated JSX.
const fonts = ["Sans-serif", "Serif", "Monospace", "Cursive", "Fantasy"];
<Combobox items={fonts}>
<ComboboxInput aria-label="Font family" placeholder="Search fonts…" />
<ComboboxContent>
<ComboboxEmpty>No fonts found.</ComboboxEmpty>
<ComboboxList>
{(item: string) => (
<ComboboxItem key={item} value={item}>
{item}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
showClear | boolean | — | |
showTrigger | boolean | — |
| Prop | Type | Default | Description |
|---|---|---|---|
error | React.ReactNode | — | API-38: the options could not be fetched — an error row (error itself when a string, else
errorLabel) with a Try again button when onRetry is set. Wins over loading. |
errorLabel | React.ReactNode | "Couldn't load the options." | |
loading | boolean | false | API-38: the options are being fetched — a spinner row (loadingLabel) so an async list is
never an empty popup. |
loadingLabel | React.ReactNode | "Loading…" | |
onRetry | (() => void) | — | API-38: fetch the options again. |
retryLabel | string | "Try again" |
| Prop | Type | Default | Description |
|---|---|---|---|
showRemove | boolean | — |
| Prop | Type | Default | Description |
|---|---|---|---|
clearLabel | string | "Clear all" | API-34: the clear-all control's accessible name. |
showClear | boolean | false | API-34: a × at the end of the field that removes every chip. |
Combobox, ComboboxList, ComboboxItem, ComboboxGroup, ComboboxLabel,
ComboboxCollection, ComboboxEmpty, ComboboxSeparator, ComboboxChipsInput,
ComboboxTrigger and ComboboxValue add no props of their own — each accepts everything the Base
UI part accepts.
Accessibility
- Renders Base UI's Combobox: the input is
role="combobox"witharia-expanded,aria-controlsandaria-activedescendant, and the popup is arole="listbox"with type-ahead, ↑/↓, Home/End and Esc. ComboboxEmptyis the engine's own polite live region, so the filtered result is announced. It stays mounted with zero height rather thandisplay: none(A11Y-3, A11Y-4) — a region the platform never observed is a region it never announces.- Focus shows on the group: while the input inside
InputGrouporComboboxChipshas focus, the group's border darkens subtly (border-ring/50, easing in over 150ms) and no background changes — text entry's exception to FOC-14 (MK 2026-09-27). An invalid field keeps its destructive border while focused, and there is no ring. The popup's search row follows the same rule. Rows, chips and the toggle keep the global background tint. - The popup portals out of the input's subtree, so
ComboboxContentre-applies the theme scope on the positioner inside the portal (OVL-13). - Rows carry the hand cursor from the one global rule in
base.css(INT-1). - While the popup is open Base UI marks the trailing addon
aria-hidden="true", so the toggle inside it is out of the tab sequence (A11Y-9) — it stays clickable, and therole="combobox"input beside it is the single tab stop that opens the list, exactly as APG's editable combobox asks.combobox.test.tsxaudits the open state with no axe exemption and pins the engine's shape, so the day Base UI stops hiding the addon the test says so.
| Contract | States tested |
|---|---|
| Behaviour | default, open, filtering, empty, multiple, disabled, invalid |
| Accessibility | native-or-base-ui-semantics, browser-accessibility-test, keyboard-navigation, live-region, invalid, disabled |
| Visual | default, focus, open, highlighted, invalid, disabled |
Do / Don't
Deviations
Upstream's file plus packages/ui/upstream/patches/combobox.patch. Every hunk:
- API-19 —
ComboboxItemprovides Item'sItemDescriptionContextand pointsaria-describedbyat anItemDescriptiononce one registers inside the option, so a two-line option reads its second line as the option's accessible description (Two-line options). A one-line option is unchanged. - FOC-1, FOC-3, FOC-6 —
ComboboxChips'focus-within:border-ringplusring-3 ring-ring/50is removed and the chips field gainsdata-field-group(the forced-colours fallback keys on it);ComboboxChipsInput'soutline-nonebecomesoutline-hiddenso the forced-colours fallback has something to repaint. The single-line field's own focus chrome comes fromInputGroup. - FOC-14 — the chips field's invalid border is
has-aria-invalid:border-destructive(and its dark counterpart), held in every state, focused or not; thehas-aria-invalidhalo rings are removed. - FOC-14 text-entry exception (MK 2026-09-27) — the chips field adds
has-[input:focus]:not-has-aria-invalid:border-ring/50withtransition-[color,background-color,border-color] duration-150 ease-out: a subtle darker border that eases in while its input has focus, and no fill (base.cssexcludes text inputs from the background tint). Thenot-has-aria-invalidguard keeps the destructive border. The single-line field and the popup search row take the same cue fromInputGroup. - INT-1 —
cursor-defaultis removed fromComboboxItem, so a row shows the hand cursor the one global rule gives every[role="option"]. - FRM-4 —
data-disabled:pointer-events-noneis dropped fromComboboxItem, andhas-disabled:pointer-events-nonefromComboboxChip, so a disabled row or chip stays hoverable and a Tooltip can explain it. - A11Y-3, A11Y-4 —
ComboboxEmptyno longer useshidden(display: none) to hide itself while the list has results. Base UI drives that element as a polite live region and documents that hiding it breaks the announcement, so it stays in the layout ath-0 overflow-hiddenand expands when the list is empty. - A11Y-2 —
ComboboxChipsInputgainsmin-h-6. Upstream's chips input measures 20px inside the 32px chips field, under the 24px target floor; the input is transparent, so nothing visible moves. - A11Y-16 — the toggle inside
ComboboxInput, the clear control and the chip's remove control each gain anaria-label. All three are icon-only<button>s upstream ships with no text, and Base UI names none of them. - API-34 —
ComboboxChipstakesshowClearandclearLabeland renders a clear-all control at the end of the chips field (Clear all).ComboboxCleartakes anaria-labeloverride, paints its × muted until hovered, and is a keyboard stop while the list is closed — Base UI ships it attabIndex={-1}— dropping out of the tab order while the list is open, when Base UI hides the addon from assistive technology (A11Y-9). - A11Y-9 — the toggle inside
ComboboxInputtakestabIndex={-1}. Base UI marks the whole trailing addonaria-hidden="true"while the popup is open, and nothing tabbable may sit inside anaria-hiddensubtree (axearia-hidden-focus, serious). The attribute is set at that one call site, so the standaloneComboboxTriggerof the Popup composition keeps its tab stop; the addon is not madeinert, so the mouse still reaches the control. - OVL-13 —
ComboboxContentreadsuseInternalThemeScope()and attaches it to the positioner inside the portal;tooling/verify-portal-theme-scope.mjsinventories this host. - BRD-1 —
ComboboxContentdraws a real 1pxborder border-borderinstead of upstream'sring-1 ring-foreground/10box-shadow outline. A border survives forced colours and matches Elevation; nothing else about the surface moves. - API-26 — Base UI's
Combobox.Inputreads theFieldcontext itself, so inside aFieldit takes its label, the rendered description and error ids, andaria-invalid. The addon's "Show suggestions" toggle drops the Field label Base UI also handsCombobox.Trigger, so the input is the one element the Field names. - API-27 — a new
ComboboxStatuspart re-exports Base UI'sCombobox.Status, the engine's own polite status region for an asynchronously loaded list. It carriesdata-slot="combobox-status", issr-onlyunlessvisible, and is never hidden withdisplay: none, so it stays mounted as a sibling ofComboboxListand only its children change. - DOC-2 —
cnis imported from@vegastack/design. - DOC-1 — prettier reflow plus the
registry-stamp.mjsprovenance header the three-copy model needs; no decision of its own. - API-38 —
ComboboxContenttakesloading,errorandonRetry: a status row after the list — a spinner, or the failure with Try again — so an async list never shows an empty popup. MK, 2026-10-26.