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.

The @rozie-ui/listbox packages

Listbox ships as six pre-compiled, per-framework packages. Install the one for your framework; there is no build step and no Rozie toolchain to set up:

PackageInstallREADME
@rozie-ui/listbox-reactnpm i @rozie-ui/listbox-reactreact/README
@rozie-ui/listbox-vuenpm i @rozie-ui/listbox-vuevue/README
@rozie-ui/listbox-sveltenpm i @rozie-ui/listbox-sveltesvelte/README
@rozie-ui/listbox-angularnpm i @rozie-ui/listbox-angularangular/README
@rozie-ui/listbox-solidnpm i @rozie-ui/listbox-solidsolid/README
@rozie-ui/listbox-litnpm i @rozie-ui/listbox-litlit/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"rozie-listbox"yesStable id base for the ARIA wiring (listbox id, per-option ids, aria-activedescendant). Give each instance on a page a distinct id.
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.
  • Give each instance on a page a distinct id so the generated option ids and aria-activedescendant references stay unique.

Pre-1.0 — APIs may change between minor versions.