Skip to content

Listbox — the cross-framework headless select

Listbox is Rozie's headless, fully-accessible select-only listbox — and the first @rozie-ui component with no third-party engine behind it. Every behaviour (roving virtual focus, full keyboard navigation, type-ahead, single + multi select) is authored once in Listbox.rozie and compiled to idiomatic 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.)

Because there is no vanilla-JS dependency, it is the purest demonstration of Rozie's native author-side primitives: $computed-derived state, parameterized @keydown modifiers, $refs-driven focus management, two-way r-model:value, scoped slots, and an $expose imperative handle. And because every visual value is a CSS custom property, it 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 generated from a single Listbox.rozie source via the package's codegen.mjs doc-automation engine. Consumers install only the one for their framework — no Rozie toolchain, no build-time compile step:

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). The per-leaf READMEs and the Props table below are generated from the same IR parse of Listbox.rozie, so they cannot drift from the compiled output (codegen.mjs asserts the structural columns of this page against ir.props on every run).

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 — no onChange → setState wiring. Because value is the component's sole model: true prop, the Angular output additionally implements ControlValueAccessor — 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;
}

Design-system bridges

Each package ships token presets that map the listbox tokens onto a known design system's published CSS variables — so the listbox automatically follows that system's light/dark theme and accent:

ts
import '@rozie-ui/listbox-react/themes/shadcn.css';    // shadcn/ui (Radix) — reads --background/--primary/--ring…
import '@rozie-ui/listbox-react/themes/material.css';  // Material 3 — reads --md-sys-color-*
import '@rozie-ui/listbox-react/themes/bootstrap.css'; // Bootstrap 5 — reads --bs-*
import '@rozie-ui/listbox-react/themes/base.css';      // the documented default token set

The full token vocabulary is in themes/base.css.

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-v1.0 — internal monorepo.