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.

Bi-directional scroll & anchoring

The feed is a two-ended stream: scrolling to the bottom appends the next page, scrolling up into the upper margin prepends the previous page (useInfiniteImages.loadEarlier). Prepends are gated on upward motion (delta < 0) once the scroll passes a 1200px threshold — so the first downward pass past that line doesn't spuriously prepend ahead of the visual baseline. Prepending shifts every coordinate, so without compensation the viewport would jump (scrollHeight grows above the read position).

Anchoring fixes this with a pure, testable pair in @repo/utils:

// Capture *before* prepending (positions still describe the old layout):
const anchor = pickAnchor(positions, window.scrollY);   // topmost visible tile + offset
// …prepend applies, positions re-laid…
const y = restoreScrollY(newPositions, anchor);          // "tile at same offset again"
window.scrollTo(0, y);

useScrollAnchor({ positions, prependCount }) wires this into a layout effect, so the restore happens before paint — the browser never displays the displaced frame. Because the restore compensates only the prepend, the user's own scroll position is preserved exactly: an E2E test holds the prepend request, measures the pre-restore scroll delta, then asserts the anchored tile lands within 80px of where the user's own scroll put it. Sealed rows keep their exact coordinates and stable object identity across prepends, so the predicate re-layout is O(delta) and no visible tile moves.