A persistently-visible list of selectable options implementing the WAI-ARIA APG listbox pattern. Unlike select, this list is always on screen with no trigger and no popup, and its cursor is a virtual-focus position (aria-activedescendant on the root, data-highlighted on the option) rather than DOM focus — which is what lets a separate control, such as a search input or a command palette's text field, hold focus and drive the list at the same time. Framed like a form control on Input's geometry, with rows resolving the shared dropdown tokens so a listbox and a menu are the same surface. Keyboard, typeahead, cursor and selection are owned by the headless primitive.
Density is set by a data-density ancestor — the Context system, not a Listbox prop.
Installation
npx primitiv add listbox
pnpm dlx primitiv add listbox
yarn dlx primitiv add listbox
bunx primitiv add listbox
Import
import{Listbox}from"@/components/ui/listbox";
Copied into your project as .primitiv-listbox — you own the file afterwards, so upgrades are opt-in.
Headless mode installs the npm package instead: @primitiv-ui/react
Anatomy
Ten parts — a row is composed, not configured, so the mark glyph is yours (installing this pulls in no icon package). Listbox.Option holds a mark (Listbox.OptionIndicator for single-select, or Listbox.OptionCheckbox for multi — one or the other, never both), an optional Listbox.OptionLeading glyph, the Listbox.OptionLabel, and an optional Listbox.OptionTrailing shortcut. Listbox.Group + Listbox.GroupLabel cluster options; Listbox.Empty is the no-results row. Only Listbox.Root, Option, Group and GroupLabel exist in the headless primitive — the row-anatomy parts and Empty are styled-surface additions.
<Listbox><ListboxGroup><ListboxGroupLabel/><ListboxOption><ListboxOptionIndicator/>{/* or ListboxOptionCheckbox */}<ListboxOptionLeading/>{/* optional */}<ListboxOptionLabel/><ListboxOptionTrailing/>{/* optional */}</ListboxOption></ListboxGroup><ListboxEmpty/>{/* the no-results row */}</Listbox>
<Listbox.Root><Listbox.Group><Listbox.GroupLabel/><Listbox.Option/></Listbox.Group><Listbox.Option/></Listbox.Root>// The mark, label and empty-state parts are styled-surface only —// in headless a row's mark and label are your own markup.
Props
Generated from source — headless rows from each *Props type’s JSDoc, styled rows from contract.json. Never hand-maintained.
Listbox.Root
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
From
Description
type* (required)
"single" | "multiple"
—
headless
Selects single-selection semantics: at most one option selected at a
time, and re-selecting the current option is a no-op rather than a
deselect.
Selects multiple-selection semantics: any number of options can be
selected and each toggles independently. Sets aria-multiselectable on
the root.
asChild
boolean
false
headless
Render a single consumer-supplied element in place of the native <div>,
with the listbox's role, tabIndex, ARIA state, data-orientation, ref
and keyboard handlers merged onto it via the Slot pattern.
children
ReactNode
—
headless
The ListboxOptionProps`Listbox.Option` and
ListboxGroupProps`Listbox.Group` elements that make up the
list.
defaultValue
string | string[]
—
headless
Value of the option selected on first render. Omit to start with nothing
selected.
Forbidden in controlled mode — use
ListboxSingleControlledProps.value.
Values of the options selected on first render. Omit to start with
nothing selected.
Forbidden in controlled mode — use
ListboxMultipleControlledProps.value.
dir
"ltr" | "rtl"
—
headless
Reading direction. In "rtl" the horizontal arrow pair is mirrored so
the cursor follows visual order — meaningful only when
ListboxRootBaseProps.orientation is "horizontal". Inherited from
the nearest DirectionProvider when omitted, falling back to
"ltr".
Called with the newly selected option's value whenever the user commits a
choice.
Called with the value the user is asking to select.
Called with the complete next set of selected values whenever the user
toggles any option.
orientation
"horizontal" | "vertical"
"vertical"
headless
Layout axis for cursor navigation. "vertical" binds ArrowUp/ArrowDown;
"horizontal" binds ArrowLeft/ArrowRight and additionally emits
aria-orientation="horizontal" (vertical is the ARIA default, so it is
left implicit). Surfaces as data-orientation for styling; it does not
itself apply any layout.
ref
Ref<HTMLDivElement>
—
headless
Forwarded to the underlying HTMLDivElement.
selectionFollowsFocus
boolean
false
headless
When true, moving the cursor with the arrow / Home / End keys or via
typeahead also selects the option it lands on — the behaviour of APG's
single-select listbox example. Left false, arrowing only moves the
highlight and the user commits with Enter or Space, which is what a
command palette or search-suggestion list needs.
value
string | string[]
—
headless
Forbidden in uncontrolled mode — use
ListboxSingleUncontrolledProps.defaultValue.
The currently selected option's value. Must be kept in sync by the caller
via ListboxSingleControlledProps.onValueChange.
Forbidden in uncontrolled mode — use
ListboxMultipleUncontrolledProps.defaultValue.
The full set of currently selected values. Must be kept in sync by the
caller via ListboxMultipleControlledProps.onValueChange.
size
"xs" | "sm" | "md" | "lg" | "xl"
md
styled
Frame + row scale; data-density scales the sizing within each size. Re-points the frame (radius, padding, focus-ring geometry) and every row knob (height/padding/gap/radius/icon) plus the typography.
Listbox.Option
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
From
Description
value* (required)
string
—
headless
Identifies this option within the listbox. It is this string — not the
visible label — that is compared against the root's selected value /
defaultValue; use children for the label.
asChild
boolean
false
headless
Render a single consumer-supplied element in place of the native <div>,
with the option's role, id, aria-selected, data-highlighted and
click handler merged onto it via the Slot pattern. The child must
accept a ref.
disabled
boolean
false
headless
Removes the option from cursor navigation, focus seeding and typeahead,
and makes it unselectable by click or key. The option stays in the DOM and
in the accessibility tree, marked aria-disabled. Also sets
data-disabled="" for CSS targeting.
ref
Ref<HTMLDivElement>
—
headless
Forwarded to the underlying HTMLDivElement.
Listbox.OptionIndicator
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.
Listbox.OptionCheckbox
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.
Listbox.OptionLeading
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.
Listbox.OptionLabel
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.
Listbox.OptionTrailing
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.
Listbox.Group
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
From
Description
asChild
boolean
false
headless
Render a single consumer-supplied element in place of the native <div>,
with the group's role and aria-label merged onto it via the
Slot pattern.
label
string
—
headless
Accessible name for the group, applied as aria-label. APG requires
every option group to carry a name — supply it either with this prop (an
invisible name) or by rendering a
ListboxGroupLabelProps`Listbox.GroupLabel` heading inside the
group, which names it via aria-labelledby instead. A rendered
GroupLabel takes precedence.
ref
Ref<HTMLDivElement>
—
headless
Forwarded to the underlying HTMLDivElement.
Listbox.GroupLabel
Extends HTMLDivElement — every native attribute of that element is accepted and forwarded.
Prop
Type
Default
From
Description
asChild
boolean
false
headless
Render a single consumer-supplied element in place of the native
<div>, with the heading's id and role="presentation" merged onto it
via the Slot pattern.
ref
Ref<HTMLDivElement>
—
headless
Forwarded to the underlying HTMLDivElement.
Listbox.Empty
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.
Styling contract
48 CSS custom properties on .primitiv-listbox — mode-agnostic. These names are the stable surface; the values are not.
The frame is the single tab stop and the cursor is virtual — aria-activedescendant on the frame, data-highlighted on the option — so DOM focus never leaves the frame. The arrow keys move along the orientation axis and wrap at both ends; printable characters run a prefix typeahead. Selection is manual by default (selectionFollowsFocus opts into select-as-you-arrow). In type="multiple", APG's modifier shortcuts are added — Shift+arrow extends, Ctrl/Cmd+A selects all or clears.
Key
Behaviour
ArrowDown / ArrowUp
Move the cursor to the next / previous option (wraps). ArrowRight / ArrowLeft when orientation is horizontal.
Home / End
Move the cursor to the first / last option.
Enter / Space
Select (single) or toggle (multiple) the option under the cursor.
character
Typeahead — jump the cursor to the next option whose label starts with the typed characters.
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.
Listbox
className:.primitiv-listbox
Attribute
Value
When
data-orientation
vertical | horizontal
vertical (the default) / horizontal
aria-invalid
true
invalid — set it yourself; there is no `invalid` prop
aria-activedescendant
<option id>
the cursor is on an option
aria-selected
true | false
selected option / unselected option
data-highlighted
""
the cursor is on this option
data-disabled
""
disabled option
Listbox.Root
Attribute
Value
When
data-orientation
vertical | horizontal
vertical (the default) / horizontal
aria-invalid
true
invalid — set it yourself; there is no `invalid` prop
aria-activedescendant
<option id>
the cursor is on an option
aria-selected
true | false
selected option / unselected option
data-highlighted
""
the cursor is on this option
data-disabled
""
disabled option
Accessibility
The frame is role="listbox" and the only tab stop; each row is a role="option" with aria-selected. Name the frame with aria-label or aria-labelledby — without a name a screen-reader user has no idea what the list is for.
The cursor is virtual focus: DOM focus never leaves the frame, which publishes aria-activedescendant pointing at the current option (marked data-highlighted). That is what lets a separate control — a search input, a command palette — hold focus and drive the list; it also means an option never matches :focus, so the cursor is styled with [data-highlighted], not :focus.
The mark parts (Listbox.OptionIndicator, Listbox.OptionCheckbox) are presentational and aria-hidden — the row's aria-selected carries the state, so a screen reader is never told the selection twice, and a multi-select row gains no second focusable control.
There is no invalid prop and no whole-list disabled — set aria-invalid on the frame yourself (the border follows, the same convention as InputGroup), and disable individual options with disabled, which drops them from navigation while keeping them discoverable.
Related: reach for Select when the list should live in a popup opened from a trigger, and for Combobox when a text field should both filter and select. Listbox is the always-visible case, and the only one whose cursor a separate control can drive.
Examples
Every example below reacts to the density control. Currently showing Styled mode.
Single select
type="single" selects at most one option; defaultValue seeds it (or value + onValueChange to control it). Each row is composed — a Listbox.OptionIndicator holding your own checkmark glyph, revealed by CSS on the selected row, beside a Listbox.OptionLabel. Click a row, or focus the list and use the arrow keys.
type="multiple" toggles options independently — value is a string array. Swap the mark for Listbox.OptionCheckbox, a checkbox drawn in CSS and filled when the row is selected (single-select uses a checkmark, multi a checkbox, deliberately). It is presentational and aria-hidden: the row's own aria-selected carries the state, so there is no second focusable control inside a row.
Listbox.Group clusters options under a Listbox.GroupLabel — a visible heading that names the group (role="group" + aria-labelledby) and sticks to the top while its options scroll past.
import{Listbox}from"@primitiv-ui/react";<Listbox.Roottype="single"defaultValue="ams"aria-label="Cities"><Listbox.Grouplabel="Western Europe">{/* GroupLabel is optional in headless; Group takes a label prop */}<Listbox.Optionvalue="ams">Amsterdam</Listbox.Option></Listbox.Group><Listbox.Grouplabel="Southern Europe">{/* ... */}<Listbox.Optionvalue="mad">Madrid</Listbox.Option></Listbox.Group></Listbox.Root>
Driven by a search input
The composition this component exists for. Because the cursor is virtual focus (aria-activedescendant), DOM focus can stay in a separate control — here a search input — while it drives the list. Forward the input's arrow/Enter keys to the frame yourself (the primitive owns the keymap only while the frame has focus), and render Listbox.Empty when nothing matches. Type to filter, then arrow through the results without leaving the field.
A row can carry more than a label. Listbox.OptionLeading holds a glyph after the mark column, and Listbox.OptionTrailing a shortcut or badge pinned to the inline-end edge — the command-palette row. The Listbox.OptionLabel takes the free space between them and truncates.
import{Listbox}from"@primitiv-ui/react";<Listbox.Roottype="single"aria-label="Commands"><Listbox.Optionvalue="search">{/* your leading icon, label and shortcut */}</Listbox.Option></Listbox.Root>