Skip to content
Component installs need the registry setup
VegaStack Design

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
stable
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 combobox

Install with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.

pnpm dlx shadcn@4.21.0 add @vegastack/combobox

Run 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

Combobox
ComboboxInput — data-slot="input-group-button"
ComboboxContent — data-slot="combobox-content" | "combobox-status"
ComboboxList — data-slot="combobox-list"
ComboboxItem — data-slot="combobox-item"
ComboboxGroup — data-slot="combobox-group"
ComboboxLabel — data-slot="combobox-label"
ComboboxCollection — data-slot="combobox-collection"
ComboboxEmpty — data-slot="combobox-empty"
ComboboxStatus — data-slot="combobox-status"
ComboboxSeparator — data-slot="combobox-separator"
ComboboxChips — data-slot="combobox-chips"
ComboboxChip — data-slot="combobox-chip" | "combobox-chip-remove"
ComboboxChipsInput — data-slot="combobox-chip-input"
ComboboxTrigger — data-slot="combobox-trigger"
ComboboxValue — data-slot="combobox-value"

Examples

Composition

Combobox
├── ComboboxInput
└── ComboboxContent
    ├── ComboboxEmpty
    └── ComboboxList
        └── ComboboxItem

Custom 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.

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

PropTypeDefaultDescription
showClearboolean—
showTriggerboolean—
PropTypeDefaultDescription
errorReact.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.
errorLabelReact.ReactNode"Couldn't load the options."
loadingbooleanfalseAPI-38: the options are being fetched — a spinner row (loadingLabel) so an async list is never an empty popup.
loadingLabelReact.ReactNode"Loading…"
onRetry(() => void)—API-38: fetch the options again.
retryLabelstring"Try again"
PropTypeDefaultDescription
showRemoveboolean—
PropTypeDefaultDescription
clearLabelstring"Clear all"API-34: the clear-all control's accessible name.
showClearbooleanfalseAPI-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" with aria-expanded, aria-controls and aria-activedescendant, and the popup is a role="listbox" with type-ahead, ↑/↓, Home/End and Esc.
  • ComboboxEmpty is the engine's own polite live region, so the filtered result is announced. It stays mounted with zero height rather than display: 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 InputGroup or ComboboxChips has 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 ComboboxContent re-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 the role="combobox" input beside it is the single tab stop that opens the list, exactly as APG's editable combobox asks. combobox.test.tsx audits 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.
ContractStates tested
Behaviourdefault, open, filtering, empty, multiple, disabled, invalid
Accessibilitynative-or-base-ui-semantics, browser-accessibility-test, keyboard-navigation, live-region, invalid, disabled
Visualdefault, focus, open, highlighted, invalid, disabled

Do / Don't

Do
Give the field a label and always render a ComboboxEmpty — it is what tells a screen-reader user the filter found nothing.
Don't
Hide ComboboxEmpty with display:none or conditional rendering; toggle its children instead.

Deviations

Upstream's file plus packages/ui/upstream/patches/combobox.patch. Every hunk:

  • API-19 — ComboboxItem provides Item's ItemDescriptionContext and points aria-describedby at an ItemDescription once 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-ring plus ring-3 ring-ring/50 is removed and the chips field gains data-field-group (the forced-colours fallback keys on it); ComboboxChipsInput's outline-none becomes outline-hidden so the forced-colours fallback has something to repaint. The single-line field's own focus chrome comes from InputGroup.
  • 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; the has-aria-invalid halo 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/50 with transition-[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.css excludes text inputs from the background tint). The not-has-aria-invalid guard keeps the destructive border. The single-line field and the popup search row take the same cue from InputGroup.
  • INT-1 — cursor-default is removed from ComboboxItem, so a row shows the hand cursor the one global rule gives every [role="option"].
  • FRM-4 — data-disabled:pointer-events-none is dropped from ComboboxItem, and has-disabled:pointer-events-none from ComboboxChip, so a disabled row or chip stays hoverable and a Tooltip can explain it.
  • A11Y-3, A11Y-4 — ComboboxEmpty no longer uses hidden (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 at h-0 overflow-hidden and expands when the list is empty.
  • A11Y-2 — ComboboxChipsInput gains min-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 an aria-label. All three are icon-only <button>s upstream ships with no text, and Base UI names none of them.
  • API-34 — ComboboxChips takes showClear and clearLabel and renders a clear-all control at the end of the chips field (Clear all). ComboboxClear takes an aria-label override, paints its × muted until hovered, and is a keyboard stop while the list is closed — Base UI ships it at tabIndex={-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 ComboboxInput takes tabIndex={-1}. Base UI marks the whole trailing addon aria-hidden="true" while the popup is open, and nothing tabbable may sit inside an aria-hidden subtree (axe aria-hidden-focus, serious). The attribute is set at that one call site, so the standalone ComboboxTrigger of the Popup composition keeps its tab stop; the addon is not made inert, so the mouse still reaches the control.
  • OVL-13 — ComboboxContent reads useInternalThemeScope() and attaches it to the positioner inside the portal; tooling/verify-portal-theme-scope.mjs inventories this host.
  • BRD-1 — ComboboxContent draws a real 1px border border-border instead of upstream's ring-1 ring-foreground/10 box-shadow outline. A border survives forced colours and matches Elevation; nothing else about the surface moves.
  • API-26 — Base UI's Combobox.Input reads the Field context itself, so inside a Field it takes its label, the rendered description and error ids, and aria-invalid. The addon's "Show suggestions" toggle drops the Field label Base UI also hands Combobox.Trigger, so the input is the one element the Field names.
  • API-27 — a new ComboboxStatus part re-exports Base UI's Combobox.Status, the engine's own polite status region for an asynchronously loaded list. It carries data-slot="combobox-status", is sr-only unless visible, and is never hidden with display: none, so it stays mounted as a sibling of ComboboxList and only its children change.
  • DOC-2 — cn is imported from @vegastack/design.
  • DOC-1 — prettier reflow plus the registry-stamp.mjs provenance header the three-copy model needs; no decision of its own.
  • API-38 — ComboboxContent takes loading, error and onRetry: 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.

On this page