Skip to content
Component installs need the registry setup— the Base UI shadcn project, the @vegastack namespace and the Cloudflare Access service token.
VegaStack Design

Combobox

A filterable, keyboard-navigable listbox behind a text input — type-to-filter, grouped items, async status, and a multi-select chip mode.

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

The 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):

Combobox — data-slot="combobox"
ComboboxChip — data-slot="combobox-chip"
ComboboxChipRemove — data-slot="combobox-chip-remove"
ComboboxChips — data-slot="combobox-chips"
ComboboxClear — data-slot="combobox-clear"
ComboboxCollection
ComboboxContent
ComboboxEmpty — data-slot="combobox-empty"
ComboboxGroup — data-slot="combobox-group"
ComboboxGroupLabel — data-slot="combobox-group-label"
ComboboxInput — data-slot="combobox-input"
ComboboxInputGroup — data-slot="combobox-input-group"
ComboboxItem — data-slot="combobox-item" | "combobox-item-indicator"
ComboboxList — data-slot="combobox-list"
ComboboxPopupInput — data-slot="combobox-popup-input" | "combobox-popup-input-wrapper"
ComboboxStatus — data-slot="combobox-status"
ComboboxTrigger — data-slot="combobox-icon" | "combobox-trigger"
ComboboxValue — data-slot="combobox-value"

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 unlike Select/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 own data-* attributes.
  • ComboboxInput — the text field that filters the list as the user types (data-slot="combobox-input", <input>). Styled like Input, on the same sm/md/lg scale.
  • 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 a ComboboxInputGroup; override className for a full-width, Select-style button trigger paired with ComboboxValue instead 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).
  • ComboboxList — the Base UI list wrapper required for keyboard navigation (data-slot="combobox-list", role="listbox"). Compose it explicitly as a sibling of ComboboxEmpty/ComboboxStatus inside ComboboxContent, not their parent — role="listbox" only permits option/group children per ARIA, and nesting ComboboxEmpty (role="status") inside it fails the aria-required-children a11y rule (verified in combobox.test.tsx).
  • ComboboxItem — a single option (data-slot="combobox-item", role="option"). Shows a trailing check when selected; tints on keyboard/hover highlight, dims when disabled.
  • 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 like ComboboxEmpty.
  • ComboboxValue — renders the selected value's label as read-only text; mainly for a button-style ComboboxTrigger, or (with a function child) to render chips in multiple mode.
  • ComboboxChips / ComboboxChip / ComboboxChipRemove — the multiple-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 a ComboboxChipRemove via 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.

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

AttributeValues
data-slot"combobox"

ComboboxInputGroup

PropTypeDefaultDescription
size"lg" | "md" | "sm"

Data attributes and CSS variables on ComboboxInputGroup

AttributeValues
data-field-group""
data-sizemirrors a prop or state value
data-slot"combobox-input-group"

ComboboxInput

PropTypeDefaultDescription
size"lg" | "md" | "sm"

Data attributes and CSS variables on ComboboxInput

AttributeValues
data-sizemirrors 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

AttributeValues
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

PropTypeDefaultDescription
size"lg" | "md" | "sm"

Data attributes and CSS variables on ComboboxTrigger

AttributeValues
data-sizemirrors 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

AttributeValues
data-slot"combobox-clear"

ComboboxContent

PropTypeDefaultDescription
alignAlign'start'Alignment relative to the anchor.
collisionPaddingPaddingFLOATING.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.
sideSide'bottom'Preferred side of the anchor to render against.
sideOffsetnumberFLOATING.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

AttributeValues
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

AttributeValues
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

AttributeValues
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

AttributeValues
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

AttributeValues
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

AttributeValues
data-slot"combobox-group-label"

ComboboxValue

PropTypeDefaultDescription
classNamestringAdditional class names for the wrapping <span> (Base UI's Value renders no element).

Data attributes and CSS variables on ComboboxValue

AttributeValues
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

AttributeValues
data-slot"combobox-chips"

ComboboxChip

PropTypeDefaultDescription
classNamestringClasses merged with the shared chip geometry.

Data attributes and CSS variables on ComboboxChip

AttributeValues
data-slot"combobox-chip"

ComboboxChipRemove

PropTypeDefaultDescription
aria-label*stringAccessible name announced to assistive tech — required, the × has no visible text.
classNamestringClasses merged with the shared remove-control geometry.

Data attributes and CSS variables on ComboboxChipRemove

AttributeValues
data-slot"combobox-chip-remove"

Accessibility

  • ComboboxInput exposes role="combobox" with aria-expanded/aria-haspopup="listbox"/ aria-autocomplete="list"; the popup is a role="listbox" of role="option" items — no manual ARIA wiring needed.
  • Selection is announced via aria-selected; the selected item shows a check indicator. Disabled items carry aria-disabled and are skipped by keyboard navigation.
  • ComboboxEmpty/ComboboxStatus are role="status" with aria-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 Esc to dismiss and return focus to the input. In multiple mode, Backspace on an empty query removes the last chip.
  • On :focus the field re-colors its border with the ring token, matching Input/Select. aria-invalid tints it destructive for use inside a Field.
KeyAction
/ Open the popup (if closed) and move the highlight between items.
EnterSelect the highlighted item and close the popup.
EscClose 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.
ContractStates tested
Behaviourdefault, disabled, empty, error, filtering, invalid, loading, open, read-only, selected
Accessibilitylabeled, status-announcement, semantic-html
Visualdefault, hover, focus, disabled, invalid, loading, error, empty, dark

Do / Don't

Do
Pass items and render ComboboxList's children as a function so typing actually filters, and keep ComboboxEmpty/ComboboxStatus as siblings of ComboboxList, not its parent.
Don't
Render static ComboboxItem children when you need type-to-filter, or reach for a Combobox when Select's fixed, unsearched list is enough.

On this page