Skip to main content

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 install @rcarls/rc-webcomponents
import '@rcarls/rc-webcomponents/rc-virtual-scroller/define';

Markup​

<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 true count) and aria-posinset (the item's real index, not its position in the rendered slice) on each item.
  • For a grid, use aria-rowcount / aria-rowindex, and aria-colcount / aria-colindex where 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

PropertyMarkupTypeDefaultDescription
axisaxisRCVirtualScrollerAxis'block'Logical axis to window along.
countcountnumberNot specifiedTotal collection size, normalized to a nonnegative integer. Invalid values become zero. Host writes are silent.
itemSizeitem-sizenumberNot specifiedEstimated 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.
itemsPerLineitems-per-linenumberNot specifiedGrid capacity override, normalized to a nonnegative integer; zero means automatic. Host writes are silent.
overscanoverscannumberNot specifiedExtra lines beyond each viewport edge, floored to an integer. Invalid/negative values become two. Host writes are silent.
disableddisabledbooleanfalseReports 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.
scrollTargetJS property onlyElement | nullnullThe 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.
firstJS property onlynumberNot specifiedRead-only first reported item index, also exposed as the computed `first` attribute.
lastJS property onlynumberNot specifiedRead-only last reported item index, or -1 for an empty range; also exposed as `last`.
rangeJS property onlyRCVirtualScrollerRangeDetail | nullNot specifiedThe 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

MethodDescription
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

EventDetail typeDescription
rc-virtual-scroller-rangeCustomEventThe visible range changed, or its geometry was re-measured.

Slots

NameDescription
(default)The consumer's single container element, holding only the items in the current range.

CSS Custom Properties

No CSS custom properties are documented in the custom elements manifest.

CSS Parts

PartDescription
spacer-startSpace along the scrolling axis standing in for the lines before the range.
spacer-endSpace along the scrolling axis standing in for the lines after the range.