Skip to content

Virtualized combobox

A multi-select text input plus a role="listbox" popup, windowed by Virtualizer, over tens of thousands of rows.

Composes Virtualizer (plus the design tokens).

Reach for this only when you cannot narrow the dataset

A windowed listbox over tens of thousands of rows is a last resort, not a starting point. If you can cut the list down first, do that instead and use the plain Combobox on the smaller result. Server-side search, filtering by category or region before a picker ever opens, and a shortlist of recent or favorite items all work. The plain Combobox is the more accessible pattern: a real option per row, simpler keyboard behavior, and nothing for assistive tech to lose track of.

Why a pattern, not a component

Combobox deliberately renders every matching option rather than windowing the list. Its own "Large list" demo names the ceiling: every matching option gets a real <li>, which is fine into the low thousands but not at real scale. This pattern is what filling that gap looks like today. It keeps the same chip-based multi-select behavior as Combobox: dismissible chips, toggle-to-select without closing the popup, and the same focus management when a chip is dismissed. What changes is the list underneath, a windowed listbox instead of a plain array, for the datasets where rendering every option would visibly lag.

Demo

27,000 rows in the dataset. Only the rows in view (plus the overscan buffer) are ever in the DOM.

How the scroll follows the active option

APG's combobox pattern expects aria-activedescendant to point at a real, currently-rendered option. Virtualizer only ever mounts the rows near the current scroll position, so most of a list this size is never in the DOM. Every keyboard move (ArrowUp/ArrowDown/Home/End) therefore goes through a two-phase commit rather than a direct index assignment. First it nudges the Virtualizer viewport's scrollTop toward the target row, using the same "nearest" math Element.scrollIntoView() does. That math is done by hand, because the target row usually is not mounted yet to call the real method on. It writes the active index, and therefore aria-activedescendant, only once that row is confirmed present. Each row reports its own mount and unmount through a small {@attach}, which is what makes that confirmation possible.

A generous overscan means a single-step arrow move resolves within the same tick, since the target row is usually already sitting in the overscan buffer just outside the visible viewport. Only big jumps take an extra frame while Virtualizer's own window catches up to the new scroll position: Home and End across tens of thousands of rows.

Selection has the same windowing constraint. The listbox carries aria-multiselectable="true", and each rendered row's aria-selected comes from the selected-ids list rather than from any DOM state. A row therefore reports the right selected state the instant Virtualizer mounts it, whether you selected it before the popup opened or a moment ago.

Source

The whole pattern, verbatim. Every import is a public export, so you can copy it into an app with the theme installed and it renders the same.