Skip to content
DocsRenderingVirtualization

Virtualization

Rendering 5,000 tiles would mean ~15,000 DOM nodes. useVirtualGrid keeps only what's on screen, so the DOM stays at a few dozen nodes and scrolling holds 60 FPS.

Spatial bands

Items are bucketed once into fixed-height bands (≈ one viewport tall). This handles masonry's non-monotonic y across columns, which a single binary search can't.

function buildBands(positions, bandHeight) {        // O(n), once per layout
  for (const [i, p] of positions.entries()) {
    const start = Math.floor(p.y / bandHeight);
    const end = Math.ceil((p.y + p.height) / bandHeight) - 1;
    for (let b = start; b <= end; b++) bands[b].push(i);
  }
}

A scroll position maps to an inclusive band range; the union of those buckets is the visible set — O(1) per frame.

No state churn on scroll

Scroll position is read from refs inside a requestAnimationFrame-throttled handler. React state updates only when the visible band range changes (crossing a boundary), never per frame. A spacer of exactly containerHeight reserves the scroll area → zero CLS.

const onScroll = rafThrottle(() => {
  const [start, end] = bandRange(scrollTop, viewportH, overscan, bandHeight);
  if (start !== prev.start || end !== prev.end) setVisible(collect(start, end));
});

Overscan & adaptive overscan

A base 500px overscan renders just outside the viewport so tiles are ready before they appear. Adaptive overscan widens it in proportion to scroll velocity, then shrinks when idle — tune both live on the benchmark.

Why not an IntersectionObserver per band? Band sentinels would add hundreds of DOM nodes and defeat the "< 100 nodes" goal. Scroll-math + bands is O(1)/frame and keeps the DOM tiny. IntersectionObserver is still used where it shines — infinite-scroll triggering.

GPU compositing

Visible tiles are absolutely positioned with transform: translate3d(x, y, 0). Position changes (resize / engine swap) animate via transform only; width and height snap instantly — layout properties are never animated.