Skip to content

Listbox — the cross-framework headless select ​

Listbox is a headless, fully-accessible select-only listbox with no third-party engine behind it. It covers the whole behaviour surface: roving virtual focus, full keyboard navigation, type-ahead, and single + multi select. The same component ships for React, Vue, Svelte, Angular, Solid, and Lit. (For a type-to-filter editable input, reach for the sibling @rozie-ui/combobox — it shares the same @rozie-ui/headless-core list spine.)

There is no vanilla-JS dependency: selection state, keyboard navigation, type-ahead, and focus management are implemented directly against the platform. Every visual value is a CSS custom property, so the listbox re-skins to any design system, with ready-made bridges for shadcn/ui, Material 3, and Bootstrap 5.

Solid-only debut

Today only @rozie-ui/listbox-solid is on npm; the other framework packages are in dogfooding.

The @rozie-ui/listbox packages ​

Listbox is designed to ship as six pre-compiled, per-framework packages, but today only @rozie-ui/listbox-solid is on npm — the rest are still in dogfooding. Install the published one for real use; the other rows below link to their workspace README for review:

PackageInstallREADME
@rozie-ui/listbox-reactnot yet published (dogfooding)react/README
@rozie-ui/listbox-vuenot yet published (dogfooding)vue/README
@rozie-ui/listbox-sveltenot yet published (dogfooding)svelte/README
@rozie-ui/listbox-angularnot yet published (dogfooding)angular/README
@rozie-ui/listbox-solidnpm i @rozie-ui/listbox-solidsolid/README
@rozie-ui/listbox-litnot yet published (dogfooding)lit/README

Each package carries only its framework peer (react + react-dom, vue, svelte, @angular/core + @angular/common + @angular/forms, solid-js, or lit + @lit-labs/preact-signals + @preact/signals-core).

Quick start ​

Pass an options array and two-way bind value:

rozie
<components>
{
  Listbox: './Listbox.rozie',
}
</components>

<data>
{
  fruit: null,
  fruits: [
    { label: 'Apple', value: 'apple' },
    { label: 'Banana', value: 'banana' },
    { label: 'Cherry', value: 'cherry' },
  ],
}
</data>

<template>
  <Listbox r-model:value="$data.fruit" :options="$data.fruits" placeholder="Pick a fruit…">
    <template #option="{ option, active, selected }">
      <span :class="{ active, selected }">{{ option.label }}</span>
    </template>
  </Listbox>
</template>

r-model:value is Rozie's two-way bind: the consumer hands Listbox a value, Listbox writes the new selection back, and the framework reconciler picks it up with no onChange → setState wiring. Because value is the component's sole model: true prop, the Angular output additionally implements ControlValueAccessor, so a Listbox is a form control ([formControl] / [(ngModel)] bind directly).

API ​

Props ​

NameTypeDefaultRuntime-updatable?Description
optionsArray[]yesThe option set. Each entry is a primitive (string/number) or an object resolved via the option* props (falling back to .label / .value / .disabled).
valueunknownnullyes (via r-model)The selected value. model: true — scalar in single-select, an array of values in multi-select. The sole model prop, so Angular emits a ControlValueAccessor.
multipleBooleanfalseyesMulti-select: value becomes an array; selecting toggles membership and keeps the popup open.
inlineBooleanfalseyesRender the results list in normal flow (static) rather than as an absolute popup, so an overflow:hidden ancestor (e.g. a command palette) can't clip it. Defaults to the standalone dropdown behavior.
disabledBooleanfalseyesDisable the control (also sets the Angular CVA disabled state).
placeholderString''yesPlaceholder text for the empty control.
closeOnSelectBooleantrueyesClose the popup after a single-select commit. Multi-select keeps it open regardless.
optionLabelFunctionnullyes(option) => string — resolve an object option's display label.
optionValueFunctionnullyes(option) => value — resolve an object option's committed value.
optionDisabledFunctionnullyes(option) => boolean — mark an option non-selectable.
idString''yesStable id base for the ARIA wiring (listbox id, per-option ids, aria-activedescendant). Empty (the default): each instance generates a unique base after mount (rozie-listbox-<n>). Set it when you need stable, predictable ids.
ariaLabelStringnullyesAccessible name for the control when there is no visible <label for>.
virtualBooleanfalseyesOpt-in vertical option windowing for long lists. When true, only the visible slice of options renders inside a bounded scrolling list (leading/trailing spacers preserve the total scroll height), windowing over the filtered option set. Default false is byte-identical to a non-windowed listbox. Pair with inline + maxHeight.
estimateRowHeightNumber36yesEstimated option row height (px) seeding the windowing engine before measureElement refines actual heights. Only consulted when virtual is on.
maxHeightString''yesA CSS length string bounding the list scroll container when virtual is on (e.g. '320px'). Mirrored to the --rozie-listbox-max-height custom property; the prop wins, the token is the fallback. Ignored when virtual is off.

Events ​

EventDescription
open-changeFired whenever the popup opens or closes. Payload { open: boolean }.
changeFired after the selection changes. Payload { value, option } (option is null when cleared).

Imperative handle ​

Declared once in the source via $expose; obtained through each framework's native ref mechanism.

MethodDescription
openOpen the popup (no-op when disabled or already open).
closeClose the popup.
toggleToggle the popup open/closed.
clearClear the selection and reset the internal query state.
focusControlMove DOM focus to the control. (Named focusControl, not focus, so it does not override the native HTMLElement.focus on the Lit element.)

Slots ​

SlotParamsDescription
selectedselected, valueCustom rendering of the select-only trigger's chosen-value display.
optionoption, index, active, selected, disabledCustom per-option rendering (the main scoped slot).
emptyqueryShown when the (filtered) option list is empty.

Theming ​

Every value the component renders is a --rozie-listbox-* CSS custom property with a built-in fallback, so it works with zero configuration yet is completely re-skinnable. Override tokens at any ancestor scope:

css
.rozie-listbox {
  --rozie-listbox-accent: #16a34a;
  --rozie-listbox-radius: 10px;
  --rozie-listbox-bg: #0b1220;
  --rozie-listbox-fg: #e5e7eb;
}

The complete token table and the design-system bridges live on the dedicated theming page.

Keyboard ​

It follows the ARIA APG "Select-Only Combobox" pattern: DOM focus stays on the control while the highlighted option is tracked virtually via aria-activedescendant.

KeyAction
↓ / ↑Open the popup / move the active option down / up (wraps, skips disabled).
Home / EndJump to the first / last enabled option.
EnterCommit the active option.
EscapeClose the popup and return focus to the control.
Space(Select-only) toggle the popup / commit the active option.
TabClose the popup and move on.
printable(Select-only) type-ahead — jump to the first option whose label starts with the typed buffer.

Accessibility ​

  • The control carries role="combobox", aria-expanded, aria-controls, and aria-activedescendant; the popup is role="listbox" (with aria-multiselectable in multi-select); each option is role="option" with aria-selected / aria-disabled.
  • Supply an accessible name via a visible <label for> pointing at the control's id, or the ariaLabel prop.
  • Each instance generates a unique id base after mount, so several listboxes on one page never share option ids or aria-activedescendant references. Set id yourself only when you need stable ids.

Pre-1.0 — APIs may change between minor versions.