#7354·ariakit

Add a standalone Listbox component family

Author: diegohazCreated Aug 31, 2026Updated Sep 4, 2026
Labelsfeature

Motivation

Add an always-visible Listbox that does not require Combobox input or popup state. It should support a standalone chooser and supply shared option selection beneath ComboboxList and/or ComboboxItem.

Keep active movement, committed selection, and typed text separate. Use the shared selection engine from #7114 so Listbox does not introduce another modifier and range-selection implementation.

Status

This issue body is the canonical Listbox design and implementation specification. LB-01 through LB-05 and LBR-01 through LBR-15 are explicit maintainer decisions. Supporting proposals and unverified claims are identified separately. No Listbox library implementation is included in this handoff.

Listbox v1 waits for #7114. Research used source baselines bbba558c15 and, for the contract audit, 425f7014a. Recheck inherited behavior if implementation starts from a later baseline.

Direction. Listbox is the base layer, not a second consumer. ComboboxItem will later be built on ListboxItem, and the Combobox store will compose the Listbox store. The first release does not perform that re-basing; it only has to keep it possible (LBR-13).

Usage example

The following API is planned, not available at the research baseline. Initial imports use lower-package module paths:

typescript
import { Listbox } from "@ariakit/react-components/listbox/listbox";
import { ListboxItem } from "@ariakit/react-components/listbox/listbox-item";
import { ListboxProvider } from "@ariakit/react-components/listbox/listbox-provider";
import { useListboxStore } from "@ariakit/react-components/listbox/listbox-store";
import { ListboxRenderer } from "@ariakit/react-components/listbox/listbox-renderer";

Single selection with an explicit commit:

typescript
<ListboxProvider defaultValue="apple">
  <Listbox aria-label="Fruit">
    <ListboxItem value="apple">Apple</ListboxItem>
    <ListboxItem value="pear">Pear</ListboxItem>
  </Listbox>
</ListboxProvider>

An array selects multiple mode. Labels do not define identity:

typescript
<ListboxProvider defaultValue={[]}>
  <Listbox aria-label="Produce to order">
    <ListboxItem value="apple-red">Apple</ListboxItem>
    <ListboxItem value="apple-green">Apple</ListboxItem>
  </Listbox>
</ListboxProvider>

An explicit store supports controlled selection, the single-mode selectOnMove option, and the forwarded selectableBehavior:

typescript
const listbox = useListboxStore({
  value,
  setValue,
  selectOnMove: false,
});

<Listbox store={listbox} aria-label="Fruit">
  <ListboxItem value="apple" typeaheadText="Apple">
    <strong>Apple</strong>
  </ListboxItem>
</Listbox>;

A large list renders through ListboxRenderer:

typescript
<ListboxProvider defaultValue={[]}>
  <Listbox aria-label="Countries">
    <ListboxRenderer items={countries}>
      {({ value, ...props }) => (
        <ListboxItem key={value} value={value} {...props}>
          {value}
        </ListboxItem>
      )}
    </ListboxRenderer>
  </Listbox>
</ListboxProvider>

The core store belongs at @ariakit/components/listbox/listbox-store as createListboxStore. React hook equivalents accompany the components. There is no initial @ariakit/react re-export or @ariakit/react/listbox entry.

Maintainer decisions

ID Decision Consequence
LB-01 Public standalone family Add Listbox, ListboxItem, provider, and store. Reuse selection beneath Combobox. Export through lower-package module paths only at first.
LB-02 string | null | readonly string[] null is empty single selection; [] is empty multiple selection. Accept readonly arrays and real empty-string values. Test a generic internal base; do not promise cross-family stores.
LB-03 Explicit commit by default Arrow keys move; Enter, Space, or click selects. Offer selectOnMove for standalone single mode.
LB-04 Wait for #7114 Compose the shared engine and inherit its toggle and supported range gestures. Keep Listbox responsible for option semantics and its adapter.
LB-05 Core and React first Keep core framework-neutral. Record Solid obligations without expanding this first release.
LBR-01 Keep #7114's controller internal Listbox ships from the same package and imports the value-keyed controller privately. No new public factory in @ariakit/components.
LBR-02 Disabled-aware selection key Withhold the selection key from disabled options, the way select mode already does.
LBR-03 No frozen mode in v1 selectableMode: "none" is a non-goal. Additive later, because it is a store option rather than a shape inference.
LBR-04 Listbox store owns selectOnMove Take the Combobox moves subscription into the Listbox store. Combobox inherits it when it later composes.
LBR-05 APG entry behavior by default The store seats activeId on the selected option, using the last selected value in multiple mode.
LBR-06 Typeahead composed by default Listbox composes CompositeTypeahead and forwards its typeahead boolean so it can be turned off.
LBR-07 orientation: "vertical" Matches createComboboxStore and createSelectStore, and leaves Left and Right free.
LBR-08 Select-all on in multiple mode Cmd/Ctrl+A selects eligible items only, and is a no-op in single mode.
LBR-09 Escape does nothing by default clearOnEscape is opt-in. A form control must not discard the user's choice on a cancel key.
LBR-10 Forward selectableBehavior The Listbox store accepts "toggle" and "replace", defaulting to "toggle".
LBR-11 Restate the alternatives Compare the minimal option against post-#7114 main, and record what the named family adds.
LBR-12 Extend the release checklist Add changesets, fixture placement, aria-multiselectable ownership, and the SelectList qualifier.
LBR-13 v1 keeps the re-basing possible Listbox ships standalone. Combobox is untouched by the first release.
LBR-14 The selection state key is value Not selectedValue. This supersedes the earlier LB-02 clarification, and carries the ordering constraint below.
LBR-18 No selection persistence by default ListboxRenderer does not keep selected options mounted. Persistence is opt-in through persistentIndices.
LBR-17 ListboxRenderer ships in v1 The first surface includes a renderer. Standalone virtualization becomes a supported feature rather than a non-goal, which makes the LBR-16 dependency a release blocker.
LBR-16 Disabled data items are never eligible #7114's range delegate must exclude items whose data says disabled, so the select-all guarantee holds for virtualized lists too.
LBR-15 Store options on the base When Combobox later composes the Listbox store, it sets the base's options rather than overriding members afterwards.

The original maintainer notes are preserved:

  • LB-01: But we won't re-export in @ariakit/react at first.
  • LB-02: Is this the same as Combobox's selectedValue?
  • LB-03: (none)
  • LB-04: Selection will be handled by https://github.com/ariakit/ariakit/issues/7114
  • LB-05: (none)
  • LBR-02: Do as much similar as Combobox as possible, because ComboboxItem will later use ListboxItem underneath. One difference is that Listbox will have value state, not selectedValue.
  • LBR-04: We will probably use the listbox store within the combobox store as well.

LB-02 clarification, revised by LBR-14. Listbox names its committed selection value, with the type string | null | readonly string[]. This is deliberately not Combobox's current name. Combobox keeps selectedValue and its "" default until the rename in the ordering constraint below completes. Combobox's deprecated value alias remains input text, not selection, until it is removed.

Decision history. The issue's own revision 3 reopened only LB-04, because the initial "toggle only first" answer conflicted with the #7114 note; the maintainer then explicitly selected the shared-engine dependency for revision 4, and that conflict is resolved. The audit rounds that produced LBR-01 through LBR-15 reopened nothing. Revision 2 recorded that revision 1 overstated one cost of LBR-02: withholding the selection key does not erase a controlled selected value, because the ["items", "selectedValue", "selectElement"] effect only seeds a default when none was supplied, and ComboboxItem derives selected from the value prop rather than the registered item. Demo settings and recommendations are not additional maintainer decisions.

Ordering constraint

value cannot be claimed on both families at once. ComboboxStoreState.value is a live state key holding the input text, deprecated in favor of inputValue, and written on every keystroke by a two-way sync at combobox-store.ts:159. createStore(initialState, ...stores) merges parent stores into one flat state, so a composed Listbox store whose value is the selection would share that field with Combobox's input text. Typing would overwrite the selection. Deprecation does not resolve this; the alias has to be removed.

The work therefore has a forced order:

  1. Ship Listbox standalone with value as its selection key. This blocks on nothing, which is what makes LBR-13 viable.
  2. Remove the deprecated Combobox value alias and its two-way sync. This is a breaking change on @ariakit/react.
  3. Rename Combobox selectedValue to value.
  4. Compose the Listbox store into the Combobox store and re-base ComboboxItem.

Steps 2 to 4 are outside this issue. Nothing in the first release may assume they have happened.

Upstream dependency on #7114

#7114's auto-published range delegate decides out-of-window eligibility from the data, filtering on item.selectable === true. It does not consult disabled. Left as is, a Shift range or selectAll that crosses unmounted rows would select a disabled option, which contradicts the eligibility rule above (LBR-16).

This is a one-predicate change upstream, not a new item-data contract:

  • disabled is already a first-class field on item data. CompositeStoreItem.disabled is declared at composite-store.ts:590, and a renderer's items are typed as the store's item type, so authors can already supply it.
  • Data-supplied disabled already reaches the store for unmounted rows. createCollectionStore seeds controlledItems from items/defaultItems, and store.item(id) falls back to that map, so the field survives for a row that is not rendered.
  • The library already depends on this. The Combobox effect that points the active item at the current selection reads !item.disabled off items, which includes controlled items.

So the ask on #7114 is that the delegate treat a datum as eligible only when it opts in and is not disabled, rather than on the opt-in flag alone. selectable and disabled stay distinct: selectable: false marks a datum that is not an option at all, such as a header or separator, while disabled marks an option that exists but cannot be chosen.

This is a release blocker, because of LBR-17. Earlier revisions of this design recorded it as a non-blocking dependency, on the grounds that Listbox made no standalone virtualization promise. Shipping ListboxRenderer in the first surface makes that promise, so a virtualized Listbox is a v1 shape and the LBR-08 guarantee has to hold on the delegate path from the first release.

Requirements

Selection and identity

  • Keep activeId as the active collection item and value as committed data. An active item need not have a mounted DOM element.
  • Infer single versus multiple mode from scalar/null versus array. Do not add a separate Listbox mode prop merely to represent the selected value shape.
  • In single mode, click, Enter, or Space selects one option. Activating the selected option keeps it selected. Empty single selection remains possible through null.
  • In multiple mode, unmodified click, Enter, or Space toggles one value. Use #7114's supported Shift gesture rules. Do not introduce a private Listbox range algorithm or imply that Mod+Shift+Arrow is in that issue's first scope.
  • Accept selectableBehavior on the store, "toggle" or "replace", defaulting to "toggle" (LBR-10). Cardinality stays inferred from the value shape; only the behavior is an explicit option.
  • Preserve selected values when options are filtered or unmounted. Do not derive selected data from visible labels or generated DOM IDs.
  • Duplicate labels are valid when values differ. Repeated values cannot represent independent Listbox choices. Preserve existing Combobox value aliasing where several items share one selected value.
  • User interaction must not select disabled options. Withhold the selection key from a disabled option, the way select mode already does at combobox-item.tsx:137. This does not mean removing a disabled value supplied by controlled or programmatic state.
  • Cmd/Ctrl+A selects every eligible item in multiple mode, and is a no-op in single mode (LBR-08). The eligibility rule is the one above, so disabled options are excluded.
  • A caller's preventDefault() must cancel the internal action without changing selection or the shared engine's anchor state. A controlled update must not produce a second uncontrolled commit.

The Listbox value adapter must distinguish null from an empty-string value. These are membership values, not a proposed public controller API:

typescript
const membershipValues = value === null
  ? []
  : typeof value === "string"
    ? [value]
    : value;

// null -> []
// ""   -> [""]
// []   -> []

Movement, commit, and entry

  • selectOnMove lives on the Listbox store as a subscription to moves, taken from the Combobox implementation at combobox-store.ts:444 without its selectElement and open guards, which a standalone list has no meaning for.
  • Because the subscription watches moves, every move commits, including typeahead and the programmatic move() call sites. With typeahead composed by default (LBR-06), a Listbox with selectOnMove enabled commits on type-ahead. This matches a native select and is intended, not incidental.
  • Entry focus seats activeId on the selected option, or on the last selected value in multiple mode (LBR-05). Seat it with setState, not move(). Only move() increments moves (composite-store.ts:561), so entry focus must not trigger a selectOnMove commit.
  • With no selection, entry falls back to existing Composite behavior. compositeElementInFocusOrder defaults to activeId === null, which keeps an empty Listbox reachable.
  • Escape does nothing by default. clearOnEscape is opt-in (LBR-09). If #7114 ships a different default, Listbox sets its own through the option mechanism in LBR-15.

List and option semantics

  • Provide a named listbox with option descendants, or groups containing options. Represent multiple mode and selected options with the appropriate ARIA attributes. Listbox owns the aria-multiselectable emission for its own element, because it knows its role statically; it must not also receive #7114's role-gated emission from Composite.
  • Keep focus separate from selection. Preserve Composite navigation, orientation, and applicable focus overrides. Tab leaves the widget; standalone Listbox has no popup hide action.
  • Listbox composes CompositeTypeahead itself and forwards its typeahead boolean (LBR-06). Text search is not inherited from Composite. The capture-phase handler is also what keeps Space from committing mid-query, so composing it is a correctness requirement rather than a convenience.
  • createListboxStore defaults orientation to "vertical" (LBR-07), matching createComboboxStore and createSelectStore. Follow Composite's roving DOM-focus default (virtualFocus: false), retain its virtual-focus option, and do not loop by default.
  • Do not use Listbox options for interactive row actions. Grid, tree, and dialog semantics remain separate patterns.
  • Use explicit ARIA labels and existing Group primitives first. Named Listbox label, check, and group helpers need defined behavior before they join the surface. The renderer left that deferral under LBR-17 and is specified below.

These requirements follow the APG Listbox pattern and WAI-ARIA listbox semantics. ARIA attributes alone do not implement keyboard behavior.

Virtualized rendering

  • ListboxRenderer ships in the first surface (LBR-17), at @ariakit/react-components/listbox/listbox-renderer. It binds the Listbox store context and types the item data, then delegates to useCompositeRenderer. It is an adapter, not a second virtualizer.
  • It does not keep selected options mounted (LBR-18). Selection persistence is opt-in, through the existing persistentIndices prop. Nothing in the Listbox contract needs a selected option to have a DOM element:
    • The active item is already persisted. CompositeRenderer unions firstIndex, activeIndex and lastIndex into persistentIndices on every render, so the item that carries the roving tabIndex is mounted by construction. The entry behavior in LBR-05 therefore works without selection persistence: seating activeId on the selected option is what mounts it.
    • Selected state is data, not DOM. Membership is keyed by value, store.item(id) falls back to controlledItems, and range and select-all eligibility for unmounted rows comes from the delegate. An unmounted option is not in the accessibility tree, so it has no ARIA to carry.
    • Form participation does not need it either. The hidden select emits an option per selected value rather than per mounted item.
  • The reason ComboboxRenderer persists selected values does not apply here. It exists so the selected item can be found by the popup's initial-focus step, which queries the DOM for [data-autofocus=true],[autofocus]. A standalone Listbox has no popup and no such query.
  • In a virtualized Listbox, per-item eligibility must be expressed in the data. An option that is not selectable at all is marked selectable: false, and an option that exists but cannot be chosen is marked disabled. Composition cannot answer for a row that never mounts, and the mount-scoped registry does not reach it.
  • Nested renderers over one store, and sibling renderers over one store, are existing shapes. Do not add a Listbox-specific range delegate; CollectionRenderer already publishes one.

Combobox compatibility and ownership

The first release does not change Combobox. These requirements protect that, and describe what a later re-basing must preserve.

  • Keep one selection owner and one item registry. Do not wrap Combobox in a second ListboxProvider that duplicates activeId or selected values. Explicit stores must override unrelated outer context.
  • Share option