Skip to content
Primitiv home
Framework
Consumption mode

Listbox

stableSource Figma

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.

Playground

Density

Preview

Amsterdam
Berlin
London
Madrid
Paris
Rome
Size
import { Listbox, ListboxOption, ListboxOptionIndicator, ListboxOptionLabel } from "@/components/ui/listbox";import { Check } from "@primitiv-ui/icons";
<Listbox size="md" type="single" defaultValue="ams" aria-label="Cities">  <ListboxOption value="ams">    <ListboxOptionIndicator><Check aria-hidden="true" /></ListboxOptionIndicator>    <ListboxOptionLabel>Amsterdam</ListboxOptionLabel>  </ListboxOption>  {/* ...more options */}</Listbox>

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

Installation

npx 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

<Listbox>  <ListboxGroup>    <ListboxGroupLabel />    <ListboxOption>      <ListboxOptionIndicator />   {/* or ListboxOptionCheckbox */}      <ListboxOptionLeading />     {/* optional */}      <ListboxOptionLabel />      <ListboxOptionTrailing />    {/* optional */}    </ListboxOption>  </ListboxGroup>  <ListboxEmpty />                 {/* the no-results row */}</Listbox>

Props

Listbox.Root

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

PropTypeDefaultFromDescription
type (required)"single" | "multiple"—headlessSelects 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.
asChildbooleanfalseheadlessRender 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.
childrenReactNode—headlessThe ListboxOptionProps`Listbox.Option` and ListboxGroupProps`Listbox.Group` elements that make up the list.
defaultValuestring | string[]—headlessValue 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"—headlessReading 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".
onValueChange((value: string) => void) | ((value: string[]) => void)—headlessCalled 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"headlessLayout 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.
refRef<HTMLDivElement>—headlessForwarded to the underlying HTMLDivElement.
selectionFollowsFocusbooleanfalseheadlessWhen 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.
valuestring | string[]—headlessForbidden 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"mdstyledFrame + 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.

PropTypeDefaultFromDescription
value (required)string—headlessIdentifies 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.
asChildbooleanfalseheadlessRender 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.
disabledbooleanfalseheadlessRemoves 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.
refRef<HTMLDivElement>—headlessForwarded 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.

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessRender 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.
labelstring—headlessAccessible 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.
refRef<HTMLDivElement>—headlessForwarded to the underlying HTMLDivElement.

Listbox.GroupLabel

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

PropTypeDefaultFromDescription
asChildbooleanfalseheadlessRender 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.
refRef<HTMLDivElement>—headlessForwarded 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

Control frame

--primitiv-listbox-surface--primitiv-listbox-fg--primitiv-listbox-border-width--primitiv-listbox-border-color--primitiv-listbox-border-color-invalid--primitiv-listbox-radius--primitiv-listbox-padding-block--primitiv-listbox-padding-inline--primitiv-listbox-max-block-size--primitiv-listbox-ring-color--primitiv-listbox-ring-width--primitiv-listbox-ring-offset--primitiv-listbox-option-height--primitiv-listbox-option-padding-inline--primitiv-listbox-option-gap--primitiv-listbox-option-radius--primitiv-listbox-option-icon-size--primitiv-listbox-option-font-family--primitiv-listbox-option-font-size--primitiv-listbox-option-font-weight--primitiv-listbox-option-line-height--primitiv-listbox-option-color--primitiv-listbox-option-color-disabled--primitiv-listbox-option-bg--primitiv-listbox-option-bg-hover--primitiv-listbox-option-bg-highlighted--primitiv-listbox-option-spacing--primitiv-listbox-indicator-color--primitiv-listbox-checkbox-size--primitiv-listbox-checkbox-radius--primitiv-listbox-checkbox-mark-size--primitiv-listbox-checkbox-bg--primitiv-listbox-checkbox-bg-selected--primitiv-listbox-checkbox-border-width--primitiv-listbox-checkbox-border-color--primitiv-listbox-checkbox-border-color-selected--primitiv-listbox-checkbox-mark-color--primitiv-listbox-empty-color--primitiv-listbox-empty-min-block-size--primitiv-listbox-label-ink-slack

Group label & separator

--primitiv-listbox-group-label-height--primitiv-listbox-group-label-padding-inline--primitiv-listbox-group-label-color--primitiv-listbox-group-label-font-family--primitiv-listbox-group-label-font-size--primitiv-listbox-group-label-font-weight--primitiv-listbox-group-label-line-height--primitiv-listbox-group-label-letter-spacing

Keyboard

KeyBehaviour
ArrowDown / ArrowUpMove the cursor to the next / previous option (wraps). ArrowRight / ArrowLeft when orientation is horizontal.
Home / EndMove the cursor to the first / last option.
Enter / SpaceSelect (single) or toggle (multiple) the option under the cursor.
characterTypeahead — jump the cursor to the next option whose label starts with the typed characters.

Data attributes

Listbox

className: .primitiv-listbox

AttributeValueWhen
data-orientationvertical | horizontalvertical (the default) / horizontal
aria-invalidtrueinvalid — set it yourself; there is no `invalid` prop
aria-activedescendant<option id>the cursor is on an option
aria-selectedtrue | falseselected 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

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.

Density
Amsterdam
Berlin
London
Madrid
Paris
Rome
import { Listbox, ListboxOption, ListboxOptionIndicator, ListboxOptionLabel } from "@/components/ui/listbox";import { Check } from "@primitiv-ui/icons";
<Listbox type="single" defaultValue="ams" aria-label="Cities">  <ListboxOption value="ams">    <ListboxOptionIndicator><Check aria-hidden="true" /></ListboxOptionIndicator>    <ListboxOptionLabel>Amsterdam</ListboxOptionLabel>  </ListboxOption>  <ListboxOption value="ber">    <ListboxOptionIndicator><Check aria-hidden="true" /></ListboxOptionIndicator>    <ListboxOptionLabel>Berlin</ListboxOptionLabel>  </ListboxOption>  {/* ...more options */}</Listbox>

Multiple select

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.

Density

Selected: ams, par

Amsterdam
Berlin
London
Madrid
Paris
Rome
import { Listbox, ListboxOption, ListboxOptionCheckbox, ListboxOptionLabel } from "@/components/ui/listbox";import { useState } from "react";
const [picked, setPicked] = useState(["ams", "par"]);
<Listbox type="multiple" value={picked} onValueChange={setPicked} aria-label="Cities">  <ListboxOption value="ams">    <ListboxOptionCheckbox />    <ListboxOptionLabel>Amsterdam</ListboxOptionLabel>  </ListboxOption>  {/* ...more options */}</Listbox>

Grouped options

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.

Density
Amsterdam
Berlin
London
Madrid
Paris
Rome
import { Listbox, ListboxGroup, ListboxGroupLabel, ListboxOption, ListboxOptionIndicator, ListboxOptionLabel } from "@/components/ui/listbox";import { Check } from "@primitiv-ui/icons";
<Listbox type="single" defaultValue="ams" aria-label="Cities">  <ListboxGroup>    <ListboxGroupLabel>Western Europe</ListboxGroupLabel>    <ListboxOption value="ams">      <ListboxOptionIndicator><Check aria-hidden="true" /></ListboxOptionIndicator>      <ListboxOptionLabel>Amsterdam</ListboxOptionLabel>    </ListboxOption>  </ListboxGroup>  <ListboxGroup>    <ListboxGroupLabel>Southern Europe</ListboxGroupLabel>    <ListboxOption value="mad">      <ListboxOptionIndicator><Check aria-hidden="true" /></ListboxOptionIndicator>      <ListboxOptionLabel>Madrid</ListboxOptionLabel>    </ListboxOption>  </ListboxGroup></Listbox>

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.

Density
Amsterdam
Berlin
London
Madrid
Paris
Rome
import { Listbox, ListboxOption, ListboxOptionIndicator, ListboxOptionLabel, ListboxEmpty } from "@/components/ui/listbox";import { Check } from "@primitiv-ui/icons";import { Input } from "@/components/ui/input";import { useRef, useState } from "react";
const listRef = useRef(null);const forward = (e) => {  if (!["ArrowDown", "ArrowUp", "Home", "End", "Enter"].includes(e.key)) return;  listRef.current?.dispatchEvent(    new KeyboardEvent("keydown", { key: e.key, bubbles: true, cancelable: true }),  );  if (e.key !== "Enter") e.preventDefault();};
<Input value={query} onChange={...} onKeyDown={forward} aria-label="Search" /><Listbox ref={listRef} type="single" value={picked} onValueChange={setPicked} aria-label="Results">  {results.length === 0 ? (    <ListboxEmpty>No matches</ListboxEmpty>  ) : (    results.map((r) => (      <ListboxOption value="{r.id}">        <ListboxOptionIndicator><Check aria-hidden="true" /></ListboxOptionIndicator>        <ListboxOptionLabel>{r.label}</ListboxOptionLabel>      </ListboxOption>    ))  )}</Listbox>

Rich rows

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.

Density
New file⌘N
Open settings⌘,
import { Listbox, ListboxOption, ListboxOptionLeading, ListboxOptionLabel, ListboxOptionTrailing } from "@/components/ui/listbox";import { Search } from "@primitiv-ui/icons";import { Kbd } from "@/components/ui/kbd";
<Listbox type="single" aria-label="Commands">  <ListboxOption value="search">    <ListboxOptionLeading><Search aria-hidden="true" /></ListboxOptionLeading>    <ListboxOptionLabel>Search files</ListboxOptionLabel>    <ListboxOptionTrailing><Kbd>⌘K</Kbd></ListboxOptionTrailing>  </ListboxOption></Listbox>