Skip to content

Combobox — the cross-framework headless combobox / autocomplete

Combobox is a headless, fully-accessible combobox / autocomplete with no third-party engine behind it. It covers the whole behaviour surface: the text input + popup listbox, aria-activedescendant keyboard navigation, client-side filtering, async/server-side mode, the selection model, and dismissal. The same component ships for React, Vue, Svelte, Angular, Solid, and Lit.

The WAI-ARIA combobox pattern — a role="combobox" input paired with a role="listbox" popup, navigated by ArrowUp / ArrowDown / Home / End with the active option tracked via aria-activedescendant and committed on Enter — is re-implemented (and frequently re-implemented inaccessibly) in every framework. Rozie owns the author-side API: the two-way r-model:value (the sole model: true prop, so a combobox is a form control), the internal query + open + active-descendant state, built-in client filtering with an async escape hatch (disableFilter + the search event), the keyboard model, and the token-themed skin.

Every visual value is a CSS custom property, so the control re-skins to any design system, with ready-made bridges for shadcn/ui, Material 3, and Bootstrap 5.

The @rozie-ui/combobox packages

Combobox 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/combobox-reactnpm i @rozie-ui/combobox-reactreact/README
@rozie-ui/combobox-vuenpm i @rozie-ui/combobox-vuevue/README
@rozie-ui/combobox-sveltenpm i @rozie-ui/combobox-sveltesvelte/README
@rozie-ui/combobox-angularnpm i @rozie-ui/combobox-angularangular/README
@rozie-ui/combobox-solidnpm i @rozie-ui/combobox-solidsolid/README
@rozie-ui/combobox-litnpm i @rozie-ui/combobox-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

Two-way bind value and hand the component an options array of { value, label }. The component owns the input text, the open/closed popup, and the active-descendant highlight; @change fires when a selection is committed:

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

<data>
{
  framework: null,
}
</data>

<script>
const frameworks = [
  { value: 'react', label: 'React' },
  { value: 'vue', label: 'Vue' },
  { value: 'svelte', label: 'Svelte' },
  { value: 'solid', label: 'Solid' },
]
</script>

<template>
  <Combobox
    r-model:value="$data.framework"
    :options="frameworks"
    placeholder="Search a framework…"
    ariaLabel="Framework"
    @change="onPick"
  />
</template>

r-model:value is Rozie's two-way bind: the consumer hands Combobox the selected value, Combobox writes the newly-picked value back, and the framework reconciler picks it up with no onChange → setState wiring. The input text is internal state, not a second model (two models would forfeit the form-control story); a search event exposes the typed query for async / server-side filtering. Because value is the component's sole model: true prop, the Angular output additionally implements ControlValueAccessor, so a Combobox is a form control ([formControl] / [(ngModel)] bind directly).

API

Props

NameTypeDefaultRuntime-updatable?Description
valueunknownnullyes (via r-model)The selected option's value — the sole model: true prop, so Angular emits a ControlValueAccessor. null when nothing is selected.
optionsArray[]yesThe option list — [{ value, label, disabled?, group? }]. label is the displayed text (and what client filtering matches against); value is what r-model:value reads/writes; an optional group string buckets the option under a matching entry of the groups prop (or a first-appearance fallback section) when grouping is active.
placeholderString''yesPlaceholder text for the empty input.
disabledBooleanfalseyesDisable the control (also sets the Angular CVA disabled state).
disableFilterBooleanfalseyesOpt out of built-in client filtering (async / server-side mode): render options as supplied and rely on the search event to refetch. Default: filter options by label against the typed query.
ariaLabelStringnullyesAccessible name for the input when there is no visible <label for> (reflected onto aria-label).
idBaseString"rozie-combobox"yesid base for the listbox + option elements (aria-activedescendant needs real ids). Set a distinct value per instance when more than one combobox is on a page. Named idBase (not id) to avoid shadowing HTMLElement.id on the Lit custom element.
inlineBooleanfalseyesRender the results list in normal flow (static) rather than as an absolutely-positioned popup — use when embedding the combobox inside an overflow:hidden container (e.g. a command palette) so the list is not clipped.
closeOnSelectBooleantrueyesClose the popup after a selection commits (default true, standard autocomplete behavior); set false to keep it open after a selection — e.g. in a multi-action surface.
optionLabelFunctionnullyesResolver override for an object option's display label — (option) => string. Falls back to the option's .label property.
optionValueFunctionnullyesResolver override for an object option's committed value — (option) => value. Falls back to the option's .value property.
optionDisabledFunctionnullyesResolver override marking an option non-selectable — (option) => boolean. Falls back to the option's .disabled property.
virtualBooleanfalseyesOpt-in vertical option windowing for long lists. When true, only the visible slice of options renders inside a bounded scrolling popup (leading/trailing spacers preserve the total scroll height), windowing over the filtered option set. Default false is byte-identical to a non-windowed combobox. Pair with inline + maxHeight. Reactive — may be flipped at runtime: toggling virtual builds or tears down the windowing engine (and resets any expanded-group state); a brief mid-flip frame renders the un-windowed full list rather than a blank popup.
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 popup scroll container when virtual is on (e.g. '320px'). Mirrored to the --rozie-combobox-list-max-height custom property; the prop wins, the token is the fallback. Ignored when virtual is off.
groupsArray[]yesOrdered section list [{ id, label }] setting group order + heading text. Options are partitioned by their optional group? string; groups present on options but absent here fall back to first-appearance order after the listed ones. Empty/absent ⇒ flat, ungrouped rendering (default).
groupCapNumber0yesCap each native section group to its first groupCap results, adding a keyboard-reachable "+N more" row that expands that group in place when activated. 0/absent = uncapped (default), byte-identical to today. Only applies to the non-virtual grouped render (groups non-empty); ignored when virtual is on.

Events

EventDescription
changeFired when the selected value changes — a user picks an option, or clear() resets it. Payload { value, option } — the newly-selected option value plus the raw source option object (null/null after a clear).
searchFired on every keystroke in the input. Payload { query } — the current text. Pair it with disableFilter to drive async / server-side filtering.

Imperative handle

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

MethodDescription
focusMove DOM focus to the text input. Deliberately named focus, overriding the inherited HTMLElement.focus on the Lit custom element; the override is intentional, and the compiler accepts it with a warning. This mirrors the slider / otp precedent; listbox took the other branch (focusControl).
clearReset the selection: clear value (emits change with { value: null }) and empty the input text. Collision-safe — not a host-element member.
seedQuery(text)Imperative only — sets the input text (and therefore the filtered option list) without touching the value model or selection state. Does not open the popup, select an option, or emit change/search. Not a second model (a combobox has a single model: true prop, value — a second model would forfeit the Angular ControlValueAccessor). Intended for repopulating the input on programmatic restore (e.g. a consumer's back-navigation).
pinOpen(boolean)Imperative only — pin the popup open so blurring the input into a host sub-surface (e.g. an action flyout) does not collapse the list. pinOpen(true) pins; pinOpen(false) unpins. Unpinning alone does not itself close the popup or restore focus — that is the host's responsibility. Render-neutral: never calling it leaves behavior unchanged.

Slots

SlotParamsDescription
optionoption, index, active, selected, disabledCustom per-option rendering. option is the raw source option object, index is its position in the filtered list, active is whether it is the active-descendant (keyboard-highlighted), selected is whether its value equals the bound value, disabled is the resolved disabled state. Omit it to render the plain resolved label.
emptyqueryRendered inside the open popup when the filtered list is empty. query is the current input text. Omit it to render the default "No results".
groupHeadinggroupCustom rendering for a group's heading (only when grouping is active — see Grouping options). group is { id, label }. Omit it to render the plain group.label.
groupMoregroup, hidden, expandCustom rendering for a capped group's "+N more" row (only when groupCap is set — see Capping groups). group is { id, label } (or null for the leading ungrouped section), hidden is the count of not-yet-shown options, expand is a zero-arg closure that expands the group in place. Omit it to render the default +{hidden} more text.

Grouping options

Pass an ordered groups prop and tag each option with a matching group id to partition the popup into semantic sections — each rendered as a role="group" block with an aria-label heading, following the WAI-ARIA listbox-with-groups pattern:

rozie
<template>
  <Combobox
    r-model:value="$data.userId"
    :options="[
      { value: 'apple', label: 'Apple', group: 'fruit' },
      { value: 'carrot', label: 'Carrot', group: 'veg' },
    ]"
    :groups="[
      { id: 'fruit', label: 'Fruit' },
      { id: 'veg', label: 'Vegetable' },
    ]"
  />
</template>

groups sets both the section order and the heading text; a group id present on an option but absent from groups falls back to a section titled with the id itself, appended after the listed ones (first-appearance order). Options with no group render in a single leading, unheaded section. Within every section, options keep their filtered/scored order — grouping is a stable re-partition, never a re-sort. The keyboard model (ArrowUp/ArrowDown/Home/End/Enter, aria-activedescendant) is unchanged: it walks the same group-ordered flat sequence, so on-screen order always matches keyboard order, and headings are never a keyboard stop. Leaving groups empty (and no option carrying group) renders the ungrouped combobox unchanged — grouping is strictly additive and opt-in. Grouping is supported only in the standard (non-virtual) render; groups × virtual windowing is not yet supported.

Capping groups

Pass groupCap alongside groups to cap each section to its first groupCap options, adding a keyboard-reachable "+N more" row when a section overflows the cap:

rozie
<template>
  <Combobox
    r-model:value="$data.userId"
    :options="OPTIONS"
    :groups="GROUPS"
    :group-cap="5"
  />
</template>

Activating the "+N more" row — Enter while it is the active-descendant, or a click/tap — expands that section only, in place: the rest of its options render inline and the more-row disappears. Expanding never writes the value model or fires change; it is purely a reveal. ArrowDown/ArrowUp rove onto the more-row like any other option and, once expanded, continue into the newly-revealed options — aria-activedescendant always resolves to a rendered option or more-row id. A section with groupCap or fewer options renders in full with no more-row. Expansion state resets whenever the option set or the typed query changes (a new result set invalidates any prior expansion). Customize the row's markup with the groupMore slot; the default reads +{hidden} more. 0/absent (the default) is uncapped, identical to plain grouping. groupCap only applies to the standard (non-virtual) grouped render, same as groups itself.

Filtering: client vs. async

By default Combobox filters the options you pass by label, case-insensitively, against the typed query — zero wiring required. For server-side or async data, set disableFilter and listen to @search: the component renders whatever options you currently hold and emits the query on each keystroke, so you can debounce, refetch, and feed the results straight back in:

rozie
<template>
  <Combobox
    r-model:value="$data.userId"
    :options="$data.results"
    disableFilter
    placeholder="Search users…"
    @search="onSearch"
  />
</template>
js
// debounced refetch — the component shows $data.results verbatim
const onSearch = (e) => debouncedFetch(e.query).then((rows) => ($data.results = rows))

Theming

Every value the component renders is a --rozie-combobox-* 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-combobox {
  --rozie-combobox-accent: #16a34a;
  --rozie-combobox-width: 20rem;
  --rozie-combobox-radius: 0.75rem;
  --rozie-combobox-list-max-height: 20rem;
}

Only cosmetic values flow through tokens; the structural rules (the relative wrapper, the absolutely-positioned popup, the input box model, the focus ring) compile per-leaf and are not consumer-overridable.

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

Keyboard

Focus the input, then type to filter and drive the popup from the keyboard:

KeyAction
typingFilters options by label (unless disableFilter), opens the popup, and emits search.
/ Open the popup (if closed) and move the active option down / up, skipping disabled options and clamping at the ends. The active option is kept scrolled into view when the list overflows the popup.
Home / EndMove the active option to the first / last selectable option.
EnterCommit the active option (writes value, fires change, closes the popup).
EscapeClose the popup without changing the selection.

Pointer interaction mirrors the keyboard: hovering an option makes it active, and selecting fires on mousedown (before the input blurs), so a click commits without the popup closing first.

Accessibility

  • The input is role="combobox" with aria-autocomplete="list", aria-expanded reflecting the popup state, aria-controls pointing at the listbox id, and aria-activedescendant pointing at the active option's id (so screen readers announce the highlighted option without moving real DOM focus).
  • The popup is role="listbox"; each option is role="option" with aria-selected and aria-disabled reflected from its data.
  • Supply an accessible name via a visible <label for> pointing at the input, or the ariaLabel prop.
  • Set a distinct idBase per instance when more than one combobox shares a page — aria-activedescendant requires unique option ids.
  • Dismissal uses the headless pattern (options select on @mousedown.prevent, the input's @blur closes the popup), so there is no document click-outside listener and therefore no cross-Lit-shadow retargeting problem.

v1 scope

The popup is positioned directly below the input (CSS position: absolute); there is no floating-ui-style auto-flip/shift to keep it on-screen near a viewport edge — a deliberate no-engine v1 limitation. See the comparison for the full list of deferrals.

Pre-1.0 — APIs may change between minor versions.