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.
import{Combobox}from"@primitiv-ui/react";import{ useState }from"react";const[query, setQuery]=useState("");const matches =FRAMEWORKS.filter((f)=> f.toLowerCase().includes(query.toLowerCase()),);<Combobox.RootonQueryChange={setQuery}><Combobox.Inputaria-label="Framework"placeholder="Pick a framework..."/><Combobox.Contentaria-label="Frameworks">{matches.map((f)=>(<Combobox.Itemvalue="{f}">{f}</Combobox.Item>))}{matches.length===0&&<Combobox.Empty>No matches</Combobox.Empty>}</Combobox.Content></Combobox.Root>
Density is set by a data-density ancestor — the Context system, not a Combobox prop.
Installation
npx primitiv add combobox
pnpm dlx primitiv add combobox
yarn dlx primitiv add combobox
bunx 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
Twelve parts. Combobox.Control is the framed field (Input's geometry) wrapping the Combobox.Input, an optional Combobox.Leading glyph and the decorative Combobox.Icon chevron. Combobox.Content is the popup listbox; each Combobox.Item holds a Combobox.ItemIndicator mark, optional Combobox.ItemLeading / Combobox.ItemTrailing slots, and its label, with Combobox.Empty for no results. Only Combobox.Root, Input, Content, Item and Empty exist in the headless primitive — the framed-control chrome and the row-anatomy parts are styled-surface additions.
<Combobox.Root><Combobox.Input/><Combobox.Content><Combobox.Item/><Combobox.Empty/></Combobox.Content></Combobox.Root>// The framed control (Control/Leading/Icon), the mark and the row// slots are styled-surface parts — a headless row is your own markup.
Props
Generated from source — headless rows from each *Props type’s JSDoc, styled rows from contract.json. Never hand-maintained.
Combobox.Root
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
From
Description
asChild
boolean
false
headless
Render the consumer's own element instead of the default one, merging
behaviour onto it.
defaultOpen
boolean
false
headless
Whether the popup is open on first render, for the uncontrolled case.
defaultValue
string
""
headless
The selected value on first render, for the uncontrolled case.
onOpenChange
(open: boolean) => void
undefined
headless
Called 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) => void
undefined
headless
Called 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) => void
undefined
headless
Called with the newly selected value when the user commits a choice,
whether by click or by Enter.
open
boolean
undefined
headless
Whether the popup is open, for the controlled case. Pair with
ComboboxRootProps.onOpenChange`onOpenChange` or it can never
change.
value
string
undefined
headless
The selected value, for the controlled case. Pair with
ComboboxRootProps.onValueChange`onValueChange`.
size
"xs" | "sm" | "md" | "lg" | "xl"
md
styled
Control + 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.
Prop
Type
Default
From
Description
asChild
boolean
false
headless
Render 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.
Prop
Type
Default
From
Description
asChild
boolean
false
headless
Render 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.
Prop
Type
Default
From
Description
value* (required)
string
—
headless
The 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.
asChild
boolean
false
headless
Render 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.
Prop
Type
Default
From
Description
asChild
boolean
false
headless
Render the consumer's own element instead of the default one, merging
behaviour onto it.
Styling contract
46 CSS custom properties on .primitiv-combobox — mode-agnostic. These names are the stable surface; the values are not.
Focus stays in the Combobox.Input (role="combobox", the tab stop); the cursor is virtual — aria-activedescendant on the input, data-highlighted on the row — so a row never matches :focus. Typing filters (via onQueryChange) and opens the popup, which lives in the top layer and light-dismisses on an outside click.
Key
Behaviour
ArrowDown / ArrowUp
Open the popup and move the cursor, seeding the first / last item when there is none.
Home / End
Move the cursor to the first / last item.
Enter
Commit the item under the cursor and close the popup.
Escape
Close the popup and restore the committed label to the field.
character
Type to filter — every keystroke fires onQueryChange, and the popup opens.
Data attributes
Emitted automatically by the headless primitive — style against these rather than adding your own state classes. Grouped by the part that emits them, since most are emitted by more than one.
Combobox
className:.primitiv-combobox
Attribute
Value
When
aria-expanded
true | false
the popup is open / the popup is closed
aria-activedescendant
<item id>
the cursor sits on an item
aria-selected
true | false
the committed item / an uncommitted item
data-highlighted
""
the cursor sits on this item
aria-invalid
true
invalid — set it on the input yourself, or cascade it from a Field.Root; there is no `invalid` prop
Combobox.Root
Attribute
Value
When
aria-expanded
true | false
the popup is open / the popup is closed
aria-activedescendant
<item id>
the cursor sits on an item
aria-selected
true | false
the committed item / an uncommitted item
data-highlighted
""
the cursor sits on this item
aria-invalid
true
invalid — 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
Every example below reacts to the density control. Currently showing Styled mode.
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.
import{Combobox}from"@primitiv-ui/react";import{ useState }from"react";const[query, setQuery]=useState("");const matches =FRAMEWORKS.filter((f)=> f.toLowerCase().includes(query.toLowerCase()),);<Combobox.RootonQueryChange={setQuery}><Combobox.Inputaria-label="Framework"placeholder="Pick a framework..."/><Combobox.Contentaria-label="Frameworks">{matches.map((f)=>(<Combobox.Itemvalue="{f}">{f}</Combobox.Item>))}{matches.length===0&&<Combobox.Empty>No matches</Combobox.Empty>}</Combobox.Content></Combobox.Root>
Search flavour
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.
import{Combobox}from"@primitiv-ui/react";import{ useState }from"react";const[query, setQuery]=useState("");const matches =FRAMEWORKS.filter((f)=> f.toLowerCase().includes(query.toLowerCase()),);<Combobox.RootonQueryChange={setQuery}><Combobox.Inputaria-label="Framework"placeholder="Pick a framework..."/><Combobox.Contentaria-label="Frameworks">{matches.map((f)=>(<Combobox.Itemvalue="{f}">{f}</Combobox.Item>))}{matches.length===0&&<Combobox.Empty>No matches</Combobox.Empty>}</Combobox.Content></Combobox.Root>
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.
import{Combobox}from"@primitiv-ui/react";import{ useState }from"react";const[query, setQuery]=useState("");const matches =FRAMEWORKS.filter((f)=> f.toLowerCase().includes(query.toLowerCase()),);<Combobox.RootonQueryChange={setQuery}><Combobox.Inputaria-label="Framework"/><Combobox.Contentaria-label="Frameworks">{matches.map((f)=>(<Combobox.Itemvalue={f}>{/* your leading glyph, label and hint */}</Combobox.Item>))}</Combobox.Content></Combobox.Root>
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.