Virtualizer
A headless windowing primitive that renders only the visible slice of a huge items array. Row heights can be uniform, known-variable, or measured at runtime.
Import
import { Virtualizer } from "@hyzer-labs/ui"Demo
Tabular data
<table> does not work: a <tr> outside a <table> loses its row semantics. For
tabular data, prefer the real Table component up to some
thousands of rows. Past that, see the Virtualized table pattern, which builds ARIA table semantics (role="table", "row", "columnheader", "cell") around this component instead.10,000 rows, but the DOM only ever holds the visible slice plus overscan. Inspect the
element and you will find only a handful of .hz-virtualizer-row nodes at any
time.
const items = Array.from({ length: 10000 }, (_, i) => `Row ${i + 1}`);
<Virtualizer {items} itemHeight={32} height={320}>
{#snippet row(item, index)}
<div class="demo-row">{item} <small>(#{index})</small></div>
{/snippet}
</Virtualizer>itemHeight as a function resolves a different height per row. Offsets are a prefix
sum, windowed with a binary search.
function itemHeight(item: Para) {
return 20 + item.lines * 20;
}
<Virtualizer items={paragraphs} {itemHeight} height={320}>
{#snippet row(item, index)}
<div class="demo-row" style="height: 100%">Row {index}: {item.lines} line(s)</div>
{/snippet}
</Virtualizer>measure measures each rendered row at runtime with a ResizeObserver, so itemHeight is only the seed estimate. This narrow
column wraps some rows onto multiple lines, so their real height is not known ahead of time.
Each one is corrected right after mount.
<Virtualizer {items} itemHeight={32} height={320} measure>
{#snippet row(item)}
<div class="demo-row-measured">{item}</div>
{/snippet}
</Virtualizer>Windowing elides off-screen rows, so a screen reader cannot count them natively. Setting role="list" on the viewport (via ...rest) plus role="listitem", aria-setsize, and aria-posinset on each row (using the snippet's absolute index) restores an accurate "item
N of total" announcement.
<Virtualizer {items} itemHeight={32} height={320} role="list" aria-label="Numbered list">
{#snippet row(item, index)}
<div role="listitem" aria-setsize={items.length} aria-posinset={index + 1} class="demo-row">
{item}
</div>
{/snippet}
</Virtualizer>Omitting height puts the Virtualizer in fluid mode: it measures its own box
with a ResizeObserver and windows against that instead of a fixed prop. A
fluid viewport must be height-constrained by CSS, whether that is a
sized parent plus height: 100%, a flex track, or max-height.
An unconstrained overflow: auto box grows to fit its content, and then windowing
renders nearly everything. Drag the container's resize handle (bottom-right corner) to see
it re-window with no scroll event.
<div class="fluid-container">
<Virtualizer {items} itemHeight={32} style="height: 100%">
{#snippet row(item, index)}
<div class="demo-row">{item} <small>(#{index})</small></div>
{/snippet}
</Virtualizer>
</div>Props
| Name | Type | Default | Note |
|---|---|---|---|
items | T[] | — | Required. |
itemHeight | number | ((item: T, index: number) => number) | — | Required. A fixed px height (uniform), or a per-item height function (known-variable). The estimate/seed when measure is true. |
height | number | — | Optional — omit for fluid. Viewport extent in px for fixed, SSR-exact windowing. Omitted, the viewport is fluid: CSS-size it and the component measures its own box at runtime. |
measure | boolean | false | Runtime-measures each rendered row via ResizeObserver; itemHeight becomes the seed estimate for unmeasured rows. |
overscan | number | 3 | Extra rows rendered above/below the visible span. |
row | Snippet<[T, number]> | — | Required. Renders one row — see the row snippet signature below. |
class | string | — | Merged after the hz-virtualizer class. |
itemHeight (union)
| Name | Type | Default | Note |
|---|---|---|---|
number | number | — | Uniform row height in px — the O(1) fast path (no measure). |
(item, index) => number | (item: T, index: number) => number | — | Known-variable — a per-row height resolved from the item/index. |
row snippet — Snippet<[item, index]>
| Name | Type | Default | Note |
|---|---|---|---|
item | T | — | The array element rendered by this row. |
index | number | — | The item's absolute index in items — not the window-local render position — so keys, striping, and aria-posinset stay correct despite windowing. |
Theme hooks
What this component promises your CSS. The reference theme styles exactly these — from @layer hz-theme, so your unlayered rules win. See Styling Components for the how.
Root class: .hz-virtualizer
Part classes
| Hook | Values | Styles |
|---|---|---|
.hz-virtualizer-sizer | child element | Full-height spacer that gives the scroller its scroll range. |
.hz-virtualizer-window | child element | The translated window holding the rendered rows. |
.hz-virtualizer-row | child element | Per-row wrapper. Also load-bearing at runtime — measured mode queries it — so it is stable by contract, not just by convention. |
Accessibility
Virtualizer is role-neutral by design. It applies no role, aria-*, or tabindex of its own. It is a rendering optimization rather than a widget, so all semantics come from the row snippet and ...rest on the viewport.
Windowing removes off-screen rows from the DOM, so any count-dependent semantics must be supplied explicitly. Set aria-setsize to the total item count and aria-posinset to the row's absolute index plus one, using the absolute index the snippet receives. That way assistive tech announces 'item N of total' correctly despite the elided DOM. See the List semantics demo below.
A keyboard-scrollable viewport is opt-in: add a tabindex of 0 (plus a role/label) through ...rest. The component adds no key handling of its own.
Virtualization can scroll a focused row out of the DOM, a known windowing hazard. Patterns that need a persistently focused off-screen row (for example, aria-activedescendant listboxes) should keep the active row rendered rather than reaching for the raw Virtualizer.