rc-virtual-scroller
Headless virtualization wrapper. It reports the visible index range for a collection you own, and reserves the off-range scroll space, so you can render a few dozen items instead of a few thousand. It never creates, recycles, or positions item DOM: you slot your own container element and render the reported slice into it with whatever you already use.
- Package
@rcarls/rc-virtual-scroller- Element
<rc-virtual-scroller>- Native dependency
- Measures the slotted container's real grid and geometry
- State model
- Declarative count, item-size, and overscan
- Main events
rc-virtual-scroller-range
Installation
- npm
- Yarn
- pnpm
npm install @rcarls/rc-webcomponents@next
yarn add @rcarls/rc-webcomponents@next
pnpm add @rcarls/rc-webcomponents@next
import '@rcarls/rc-webcomponents/rc-virtual-scroller/define';
Usage
<rc-virtual-scroller count="4000" item-size="288">
<ul class="cards">
<!-- only the items in the current range -->
</ul>
</rc-virtual-scroller>
const scroller = document.querySelector('rc-virtual-scroller');
const render = ({ start, end }) => renderItems(recipes.slice(start, end), start);
// The element may have measured before this listener existed.
if (scroller.range) render(scroller.range);
scroller.addEventListener('rc-virtual-scroller-range', (event) => render(event.detail));
The element measures on its own schedule, which can be before your listener is
attached, and it never re-sends an unchanged range. Render once from the
read-only range property when you subscribe, then follow the event.
That split is the whole point. The markup, the CSS layout, and the
accessibility semantics stay with the code that owns them, so a native
grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr)) keeps working
untouched, <ul> still contains <li>, and a keyed framework list still
reuses the nodes that stayed in range.
Live demo
Why not content-visibility: auto
CSS containment skips layout and paint for offscreen content, but a custom element upgrades when it is parsed or inserted, before style is resolved. Containment cannot prevent element construction, so a collection of a few thousand custom elements still pays for all of them up front. Virtualization is the only thing that avoids that cost.
Axis, direction, and writing mode
axis is logical. block, the default, windows lines that stack the way
paragraphs do: top to bottom in horizontal text, right to left in
vertical-rl. inline windows items running the way text runs, such as a
horizontal shelf. For the inline axis, lay the container's items along it with
display: flex or a grid with grid-auto-flow: column.
<rc-virtual-scroller axis="inline" count="500" item-size="160">
<ul class="shelf">
<!-- display: flex -->
</ul>
</rc-virtual-scroller>
Direction and writing mode come from the element's computed style, so RTL pages and vertical text need no configuration.
Geometry
Virtualization supports sequential DOM-order collections with uniform line pitch and capacity; the final line may be partial. Use ordinary lists, shelves without wrapping, regular grids, or uniform numeric grid spans. Masonry, variable-height lines, dense placement, and reordered collections are outside this contract. When a sampled layout is demonstrably unsupported, the component reports the whole collection, removes its spacers, and warns in development.
Automatic grid measurement reads resolved tracks and their gaps, so differently
aligned items in the same track do not become separate lines. Uniform spans,
including 1 / -1, count as items rather than individual tracks. Container
queries and auto-fill remain authoritative.
For ambiguous uniform grid placement, set items-per-line to the number of
items in each line. The override controls capacity, not pitch or CSS placement;
it does not enable virtualization of variable-height layouts. Remove the attribute,
or assign itemsPerLine = 0, to restore automatic inference.
<rc-virtual-scroller axis="inline" count="500" item-size="160" items-per-line="1">
<ul class="shelf">
<!-- One card per column, spanning named row tracks. -->
</ul>
</rc-virtual-scroller>
count is the true total. item-size estimates line pitch until it can be
measured; it never overrides measured geometry. overscan defaults to two
extra lines at each viewport edge. A one-line slice retains its established
geometry instead of reverting to a contradictory track-count estimate.
Numeric inputs are normalized: count and items-per-line are floored
nonnegative integers with a fallback of zero; overscan is floored with a
fallback of two for negative values, infinity, or NaN. Zero, negative, or invalid
item-size becomes zero. Without a usable pitch the full collection is
reported so it can be measured.
rc-virtual-scroller-range reports the effective itemsPerLine, lineSize,
and measured (whether pitch comes from real layout). first, last, and
range are read-only outputs. The first and last attributes report the
computed state; author writes do not control the window.
The nearest scrolling ancestor is found through slots and shadow hosts.
Document scrolling and viewport resizing are supported. Set scrollTarget
when the intended scrollport differs from the automatically resolved ancestor.
Resizing the scrollport fills newly visible space even when the host itself
does not resize. A pane hidden with display: none retains its mounted slice
and geometry until layout becomes available again.
Call measure() after changes that observers cannot see, such as a stylesheet
writing-mode change that preserves logical size, a font/theme change that only
affects item internals, or repositioning the collection without resizing it.
Consumers should commit the requested slice promptly; after an asynchronous
render commit, call measure() if the container's children and size did not
change.
scrollToIndex(index, { align, behavior }) uses the current line pitch.
Alignment is start (default), center, end, or nearest. Finite
indices are floored and clamped; infinity and NaN are ignored.
disabled reports the full collection with zero spacers.
To migrate code that assigned first or last, use scrollToIndex()
for navigation and read range for rendering. Supply items-per-line
when a uniform grid's placement cannot be inferred.
Accessibility
A virtualized set must still tell assistive technology how big it really is, because the DOM no longer says so. The element does not write these for you: the roles belong to your markup.
- For a list, put
aria-setsize(the truecount) andaria-posinset(the item's real index, not its position in the rendered slice) on each item. - For a grid, use
aria-rowcount/aria-rowindex, andaria-colcount/aria-colindexwhere columns are also virtualized. - Set
aria-busy="true"on the container while a batch is being swapped in, as the WAI-ARIA APG feed pattern describes.
The one thing the element does handle is focus. Unmounting the focused element
moves focus to <body> with no error and no visible cause, which is how a
keyboard user silently loses their place. The reported range is always
widened to include the focused item, contiguously, so your render stays a
plain slice; it collapses again as soon as focus moves.
The element sets overflow-anchor: none on itself. Browser scroll anchoring
exists to hold the reading position when content above the viewport changes
size, which is what this element does every frame, so leaving it on makes the
two fight and the scroll position drift.
API
Properties
| Property | Markup | Type | Default | Description |
|---|---|---|---|---|
axis | axis | RCVirtualScrollerAxis | 'block' | Logical axis to window along. |
count | count | number | Not specified | Total collection size, normalized to a nonnegative integer. Invalid values become zero. Host writes are silent. |
itemSize | item-size | number | Not specified | Estimated line pitch in pixels, used for the first frame and whenever no line is available to measure. Get this roughly right: it is what the very first `rc-virtual-scroller-range` is computed from, and what a restored scroll position lands against. Invalid or nonpositive values become zero. Host writes are silent. |
itemsPerLine | items-per-line | number | Not specified | Grid capacity override, normalized to a nonnegative integer; zero means automatic. Host writes are silent. |
overscan | overscan | number | Not specified | Extra lines beyond each viewport edge, floored to an integer. Invalid/negative values become two. Host writes are silent. |
disabled | disabled | boolean | false | Reports the whole collection as the range and stops measuring. An escape hatch for short collections, printing, and A/B measurement; the consumer's render path stays the same either way. |
scrollTarget | JS property only | Element | null | null | The scroll container to measure against. Defaults to the nearest scrolling ancestor. Set this when the ancestor only becomes scrollable after its own upgrade, or when the intended scrollport is further up than the first `overflow: auto` element. |
first | JS property only | number | Not specified | Read-only first reported item index, also exposed as the computed `first` attribute. |
last | JS property only | number | Not specified | Read-only last reported item index, or -1 for an empty range; also exposed as `last`. |
range | JS property only | RCVirtualScrollerRangeDetail | null | Not specified | The most recently reported range, or `null` before the first measurement. The element measures on its own schedule, which can be before a consumer has attached its listener; render from this once when subscribing, then follow `rc-virtual-scroller-range`. Identical ranges are never re-sent, so a subscriber that skips this can wait for an event that will not come. |
Methods
| Method | Description |
|---|---|
scrollToIndex(index: number, options: { align?: RCVirtualScrollerAlign; behavior?: ScrollBehavior }) | Scrolls the item at `index` into view along `axis`. Accurate to the current line pitch, which means accurate to `item-size` until at least one line has rendered. |
measure() | Re-measures the slotted container and re-evaluates the range. Call this when something outside the element's own observers changed the item size, such as a font load or a theme swap. |
Events
| Event | Detail type | Description |
|---|---|---|
rc-virtual-scroller-range | CustomEvent | The visible range changed, or its geometry was re-measured. |
Slots
| Name | Description |
|---|---|
(default) | The consumer's single container element, holding only the items in the current range. |
CSS Parts
| Part | Description |
|---|---|
spacer-start | Space along the scrolling axis standing in for the lines before the range. |
spacer-end | Space along the scrolling axis standing in for the lines after the range. |