Appearance
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:
| Package | Install | README |
|---|---|---|
@rozie-ui/listbox-react | npm i @rozie-ui/listbox-react | react/README |
@rozie-ui/listbox-vue | npm i @rozie-ui/listbox-vue | vue/README |
@rozie-ui/listbox-svelte | npm i @rozie-ui/listbox-svelte | svelte/README |
@rozie-ui/listbox-angular | npm i @rozie-ui/listbox-angular | angular/README |
@rozie-ui/listbox-solid | npm i @rozie-ui/listbox-solid | solid/README |
@rozie-ui/listbox-lit | npm i @rozie-ui/listbox-lit | 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
| Name | Type | Default | Runtime-updatable? | Description |
|---|---|---|---|---|
options | Array | [] | yes | The option set. Each entry is a primitive (string/number) or an object resolved via the option* props (falling back to .label / .value / .disabled). |
value | unknown | null | yes (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. |
multiple | Boolean | false | yes | Multi-select: value becomes an array; selecting toggles membership and keeps the popup open. |
inline | Boolean | false | yes | Render 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. |
disabled | Boolean | false | yes | Disable the control (also sets the Angular CVA disabled state). |
placeholder | String | '' | yes | Placeholder text for the empty control. |
closeOnSelect | Boolean | true | yes | Close the popup after a single-select commit. Multi-select keeps it open regardless. |
optionLabel | Function | null | yes | (option) => string — resolve an object option's display label. |
optionValue | Function | null | yes | (option) => value — resolve an object option's committed value. |
optionDisabled | Function | null | yes | (option) => boolean — mark an option non-selectable. |
id | String | "rozie-listbox" | yes | Stable id base for the ARIA wiring (listbox id, per-option ids, aria-activedescendant). Give each instance on a page a distinct id. |
ariaLabel | String | null | yes | Accessible name for the control when there is no visible <label for>. |
virtual | Boolean | false | yes | Opt-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. |
estimateRowHeight | Number | 36 | yes | Estimated option row height (px) seeding the windowing engine before measureElement refines actual heights. Only consulted when virtual is on. |
maxHeight | String | '' | yes | A 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
| Event | Description |
|---|---|
open-change | Fired whenever the popup opens or closes. Payload { open: boolean }. |
change | Fired 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.
| Method | Description |
|---|---|
open | Open the popup (no-op when disabled or already open). |
close | Close the popup. |
toggle | Toggle the popup open/closed. |
clear | Clear the selection and reset the internal query state. |
focusControl | Move DOM focus to the control. (Named focusControl, not focus, so it does not override the native HTMLElement.focus on the Lit element.) |
Slots
| Slot | Params | Description |
|---|---|---|
selected | selected, value | Custom rendering of the select-only trigger's chosen-value display. |
option | option, index, active, selected, disabled | Custom per-option rendering (the main scoped slot). |
empty | query | Shown 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.
| Key | Action |
|---|---|
↓ / ↑ | Open the popup / move the active option down / up (wraps, skips disabled). |
Home / End | Jump to the first / last enabled option. |
Enter | Commit the active option. |
Escape | Close the popup and return focus to the control. |
Space | (Select-only) toggle the popup / commit the active option. |
Tab | Close 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, andaria-activedescendant; the popup isrole="listbox"(witharia-multiselectablein multi-select); each option isrole="option"witharia-selected/aria-disabled. - Supply an accessible name via a visible
<label for>pointing at the control'sid, or theariaLabelprop. - Give each instance on a page a distinct
idso the generated option ids andaria-activedescendantreferences stay unique.