Skip to content
Primitiv home
Framework
Consumption mode

Combobox

stableSource Figma

An editable text field with a filtered popup listbox, implementing the WAI-ARIA APG combobox pattern. The control is Input verbatim — same framed-control/{size}/* geometry, same states — wrapped so a trailing chevron can sit beside the field, and the popup is a floating panel at elevation/overlay whose rows resolve the shared dropdown tokens, so a combobox listbox and a menu are the same surface. Filtering is consumer-owned: there is no filter prop. Keyboard, virtual-focus cursor, the query/value split and the open state all belong to the headless primitive.

Playground

Density

Preview

Size
import { Combobox, ComboboxControl, ComboboxInput, ComboboxIcon, ComboboxContent, ComboboxItem, ComboboxItemIndicator, ComboboxEmpty } from "@/components/ui/combobox";import { Check, ChevronDown } from "@primitiv-ui/icons";import { useState } from "react";
const [query, setQuery] = useState("");const matches = FRAMEWORKS.filter((f) =>  f.toLowerCase().includes(query.toLowerCase()),);
<Combobox size="md" onQueryChange={setQuery}>  <ComboboxControl>    <ComboboxInput aria-label="Framework" placeholder="Pick a framework..." />    <ComboboxIcon><ChevronDown aria-hidden="true" /></ComboboxIcon>  </ComboboxControl>  <ComboboxContent aria-label="Frameworks">    {matches.map((f) => (      <ComboboxItem value="{f}">        <ComboboxItemIndicator><Check aria-hidden="true" /></ComboboxItemIndicator>        {f}      </ComboboxItem>    ))}    {matches.length === 0 && <ComboboxEmpty>No matches</ComboboxEmpty>}  </ComboboxContent></Combobox>

Density is set by a data-density ancestor — the Context system, not a Combobox prop.

Installation

npx primitiv add combobox

Import

import { Combobox } from "@/components/ui/combobox";

Copied into your project as .primitiv-combobox — you own the file afterwards, so upgrades are opt-in.

Headless mode installs the npm package instead: @primitiv-ui/react

Anatomy

<Combobox>  <ComboboxControl>    <ComboboxLeading />     {/* optional */}    <ComboboxInput />    <ComboboxIcon />        {/* the chevron */}  </ComboboxControl>  <ComboboxContent>    <ComboboxItem>      <ComboboxItemIndicator />      <ComboboxItemLeading />   {/* optional */}      {/* label */}      <ComboboxItemTrailing />  {/* optional */}    </ComboboxItem>    <ComboboxEmpty />  </ComboboxContent></Combobox>

Props

Combobox.Root

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessRender the consumer's own element instead of the default one, merging behaviour onto it.
defaultOpenbooleanfalseheadlessWhether the popup is open on first render, for the uncontrolled case.
defaultValuestring""headlessThe selected value on first render, for the uncontrolled case.
onOpenChange(open: boolean) => voidundefinedheadlessCalled when the combobox wants the popup opened or closed. Under a controlled ComboboxRootProps.open`open` the combobox only asks — the parent decides.
onQueryChange(query: string) => voidundefinedheadlessCalled with the input's text on every keystroke. Filtering is consumer-owned — narrow the options you render in response. The component deliberately ships no filter prop.
onValueChange(value: string) => voidundefinedheadlessCalled with the newly selected value when the user commits a choice, whether by click or by Enter.
openbooleanundefinedheadlessWhether the popup is open, for the controlled case. Pair with ComboboxRootProps.onOpenChange`onOpenChange` or it can never change.
valuestringundefinedheadlessThe selected value, for the controlled case. Pair with ComboboxRootProps.onValueChange`onValueChange`.
size"xs" | "sm" | "md" | "lg" | "xl"mdstyledControl + panel + row scale, set once on the root and inherited by every part; data-density scales the sizing within each size. Re-points the control's framed-control knobs, the panel's dropdown geometry and every row knob plus the typography.

Combobox.Control

Styled surface only — the copied file adds this region. @primitiv-ui/react exports no such part, so under Headless it is your own element.

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

No props of its own.

Combobox.Leading

Styled surface only — the copied file adds this region. @primitiv-ui/react exports no such part, so under Headless it is your own element.

Extends HTMLSpanElement — every native attribute of that element is accepted and forwarded.

No props of its own.

Combobox.Input

Extends HTMLInputElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessRender the consumer's own element instead of the default one, merging behaviour onto it.

Combobox.Icon

Styled surface only — the copied file adds this region. @primitiv-ui/react exports no such part, so under Headless it is your own element.

Extends HTMLSpanElement — every native attribute of that element is accepted and forwarded.

No props of its own.

Combobox.Content

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessRender the consumer's own element instead of the default one, merging behaviour onto it.

Combobox.Item

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
value (required)string—headlessThe value committed when this item is chosen. This narrows the native value attribute, so value is Omit-ted from the base props above — without that the two resolve to an intersection artifact that leaks into consumer types and the generated prop tables.
asChildbooleanfalseheadlessRender the consumer's own element instead of the default one, merging behaviour onto it.

Combobox.ItemIndicator

Styled surface only — the copied file adds this region. @primitiv-ui/react exports no such part, so under Headless it is your own element.

Extends HTMLSpanElement — every native attribute of that element is accepted and forwarded.

No props of its own.

Combobox.ItemLeading

Styled surface only — the copied file adds this region. @primitiv-ui/react exports no such part, so under Headless it is your own element.

Extends HTMLSpanElement — every native attribute of that element is accepted and forwarded.

No props of its own.

Combobox.ItemLabel

Styled surface only — the copied file adds this region. @primitiv-ui/react exports no such part, so under Headless it is your own element.

Extends HTMLSpanElement — every native attribute of that element is accepted and forwarded.

No props of its own.

Combobox.ItemTrailing

Styled surface only — the copied file adds this region. @primitiv-ui/react exports no such part, so under Headless it is your own element.

Extends HTMLSpanElement — every native attribute of that element is accepted and forwarded.

No props of its own.

Combobox.Empty

Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessRender the consumer's own element instead of the default one, merging behaviour onto it.

Styling contract

Control frame

--primitiv-combobox-bg--primitiv-combobox-fg--primitiv-combobox-placeholder-color--primitiv-combobox-border-color--primitiv-combobox-border-color-focus--primitiv-combobox-border-color-invalid--primitiv-combobox-border-width--primitiv-combobox-radius--primitiv-combobox-height--primitiv-combobox-padding-inline--primitiv-combobox-gap--primitiv-combobox-icon-size--primitiv-combobox-icon-color--primitiv-combobox-font-family--primitiv-combobox-font-size--primitiv-combobox-font-weight--primitiv-combobox-line-height--primitiv-combobox-label-ink-slack--primitiv-combobox-offset--primitiv-combobox-indicator-color--primitiv-combobox-empty-color--primitiv-combobox-empty-min-block-size

Panel

--primitiv-combobox-content-surface--primitiv-combobox-content-fg--primitiv-combobox-content-border-width--primitiv-combobox-content-border-color--primitiv-combobox-content-shadow--primitiv-combobox-content-radius--primitiv-combobox-content-padding-block--primitiv-combobox-content-padding-inline--primitiv-combobox-content-min-inline-size--primitiv-combobox-content-max-block-size

Item

--primitiv-combobox-item-height--primitiv-combobox-item-padding-inline--primitiv-combobox-item-gap--primitiv-combobox-item-radius--primitiv-combobox-item-icon-size--primitiv-combobox-item-font-family--primitiv-combobox-item-font-size--primitiv-combobox-item-font-weight--primitiv-combobox-item-line-height--primitiv-combobox-item-color--primitiv-combobox-item-bg--primitiv-combobox-item-bg-hover--primitiv-combobox-item-bg-highlighted--primitiv-combobox-item-spacing

Keyboard

KeyBehaviour
ArrowDown / ArrowUpOpen the popup and move the cursor, seeding the first / last item when there is none.
Home / EndMove the cursor to the first / last item.
EnterCommit the item under the cursor and close the popup.
EscapeClose the popup and restore the committed label to the field.
characterType to filter — every keystroke fires onQueryChange, and the popup opens.

Data attributes

Combobox

className: .primitiv-combobox

AttributeValueWhen
aria-expandedtrue | falsethe popup is open / the popup is closed
aria-activedescendant<item id>the cursor sits on an item
aria-selectedtrue | falsethe committed item / an uncommitted item
data-highlighted""the cursor sits on this item
aria-invalidtrueinvalid — set it on the input yourself, or cascade it from a Field.Root; there is no `invalid` prop

Accessibility

  • The field is role="combobox" with aria-expanded and aria-controls pointing at the popup role="listbox"; each row is a role="option". Name the field with aria-label or aria-labelledby (or a Field.Label), and the popup with its own aria-label.
  • The cursor is virtual focus: DOM focus never leaves the input, which keeps its focus ring while the popup is open and publishes aria-activedescendant at the current row (marked data-highlighted). A row never matches :focus, so the cursor is styled with [data-highlighted]. Both the ring ("keystrokes go here") and the cursor tint ("Enter picks this") showing at once is correct.
  • The popup is a top-layer popover="auto" panel: it paints above the whole page (no z-index needed) and light-dismisses on an outside click, and it is unmounted while closed — so a screen reader never meets a listbox that looks shut.
  • Combobox.Icon is a decorative aria-hidden chevron with pointer-events: none — a click aimed at it falls through to the field. Unlike Select, the frame is a <div> wrapping an <input>, so the chevron does not open the popup; typing or ArrowDown does.
  • There is no invalid prop and no root disabled — set aria-invalid or disabled on the Combobox.Input (or cascade aria-invalid from a Field.Root), and the frame follows via :has().
  • Related: reach for Select when the choices are fixed and need no typing, and Listbox for an always-visible list with no field. Combobox is the case where a text field both filters and selects.

Examples

A framework picker

The canonical combobox: an editable field with a trailing chevron over a filtered popup. Filtering is yours — there is no filter prop; onQueryChange reports every keystroke and you render the matching items (which keeps async loading, fuzzy matching and sorting where the data lives). Render Combobox.Empty when nothing matches. Type to filter, or press ArrowDown to open the full list.

Density
import { Combobox, ComboboxControl, ComboboxInput, ComboboxIcon, ComboboxContent, ComboboxItem, ComboboxItemIndicator, ComboboxEmpty } from "@/components/ui/combobox";import { Check, ChevronDown } from "@primitiv-ui/icons";import { useState } from "react";
const [query, setQuery] = useState("");const matches = FRAMEWORKS.filter((f) =>  f.toLowerCase().includes(query.toLowerCase()),);
<Combobox onQueryChange={setQuery}>  <ComboboxControl>    <ComboboxInput aria-label="Framework" placeholder="Pick a framework..." />    <ComboboxIcon><ChevronDown aria-hidden="true" /></ComboboxIcon>  </ComboboxControl>  <ComboboxContent aria-label="Frameworks">    {matches.map((f) => (      <ComboboxItem value="{f}">        <ComboboxItemIndicator><Check aria-hidden="true" /></ComboboxItemIndicator>        {f}      </ComboboxItem>    ))}    {matches.length === 0 && <ComboboxEmpty>No matches</ComboboxEmpty>}  </ComboboxContent></Combobox>

Same component, dressed as a search field: drop the chevron and put a magnifier in the Combobox.Leading slot. It is a documented variation rather than a second component — the behaviour is identical, only the standing glyph moves to the start of the field.

Density
import { Combobox, ComboboxControl, ComboboxLeading, ComboboxInput, ComboboxContent, ComboboxItem, ComboboxItemIndicator, ComboboxEmpty } from "@/components/ui/combobox";import { Check, Search } from "@primitiv-ui/icons";import { useState } from "react";
const [query, setQuery] = useState("");const matches = FRAMEWORKS.filter((f) =>  f.toLowerCase().includes(query.toLowerCase()),);
<Combobox onQueryChange={setQuery}>  <ComboboxControl>    <ComboboxLeading><Search aria-hidden="true" /></ComboboxLeading>    <ComboboxInput aria-label="Framework" placeholder="Pick a framework..." />  </ComboboxControl>  <ComboboxContent aria-label="Frameworks">    {matches.map((f) => (      <ComboboxItem value="{f}">        <ComboboxItemIndicator><Check aria-hidden="true" /></ComboboxItemIndicator>        {f}      </ComboboxItem>    ))}    {matches.length === 0 && <ComboboxEmpty>No matches</ComboboxEmpty>}  </ComboboxContent></Combobox>

Rich rows

A row can carry more than a mark and a label. Combobox.ItemLeading holds a glyph after the mark column and Combobox.ItemTrailing a badge or hint pinned to the inline-end edge. Keep the label a plain text node between them — the headless layer reads the committed label from a row's text only when it is a bare string, so wrapping it would make the field show the item's value instead.

Density
import { Combobox, ComboboxControl, ComboboxInput, ComboboxIcon, ComboboxContent, ComboboxItem, ComboboxItemLeading, ComboboxItemTrailing, ComboboxEmpty } from "@/components/ui/combobox";import { ChevronDown } from "@primitiv-ui/icons";import { Kbd } from "@/components/ui/kbd";import { useState } from "react";
const [query, setQuery] = useState("");const matches = FRAMEWORKS.filter((f) =>  f.toLowerCase().includes(query.toLowerCase()),);
<Combobox onQueryChange={setQuery}>  <ComboboxControl>    <ComboboxInput aria-label="Framework" placeholder="Pick a framework..." />    <ComboboxIcon><ChevronDown aria-hidden="true" /></ComboboxIcon>  </ComboboxControl>  <ComboboxContent aria-label="Frameworks">    {matches.map((f) => (      <ComboboxItem key={f} value={f}>        <ComboboxItemLeading><FrameworkGlyph /></ComboboxItemLeading>        {f}        <ComboboxItemTrailing><Kbd>{count}</Kbd></ComboboxItemTrailing>      </ComboboxItem>    ))}  </ComboboxContent></Combobox>

Controlled

Pass value and onValueChange and the parent owns the committed selection — needed to react to a choice, persist it, or set it from elsewhere. onValueChange fires when the user commits (by click or Enter), separately from onQueryChange (every keystroke) and onOpenChange (the popup). Omit value for the uncontrolled form, or seed it with defaultValue.

Density

Selected: nothing

import { Combobox, ComboboxControl, ComboboxInput, ComboboxIcon, ComboboxContent, ComboboxItem, ComboboxItemIndicator, ComboboxEmpty } from "@/components/ui/combobox";import { Check, ChevronDown } from "@primitiv-ui/icons";import { useState } from "react";
const [query, setQuery] = useState("");const [value, setValue] = useState("");const matches = FRAMEWORKS.filter((f) =>  f.toLowerCase().includes(query.toLowerCase()),);
<Combobox value={value} onValueChange={setValue} onQueryChange={setQuery}>  <ComboboxControl>    <ComboboxInput aria-label="Framework" placeholder="Pick a framework..." />    <ComboboxIcon><ChevronDown aria-hidden="true" /></ComboboxIcon>  </ComboboxControl>  <ComboboxContent aria-label="Frameworks">    {matches.map((f) => (      <ComboboxItem value="{f}">        <ComboboxItemIndicator><Check aria-hidden="true" /></ComboboxItemIndicator>        {f}      </ComboboxItem>    ))}  </ComboboxContent></Combobox>