Two-dimensional windowing is arithmetic, not observation: compute the visible row and column range from the scroller's scrollTop/scrollLeft and the cell sizes each frame the scroll changes, recycle cells into that range, and use ResizeObserver for the container size (which changes columns and overscan) — IntersectionObserver is useful only for coarse jobs like pausing the grid when it is off-screen.

Problem / Scenario Context

A data-analysis tool shows a 50,000 × 200 cell spreadsheet. A photo library shows 30,000 thumbnails in a responsive grid whose column count depends on the window width. Both started with the one-dimensional pattern from building a windowed virtual list with IntersectionObserver — sentinels at the edges of the rendered block — and both struggled: sentinels do not scale to two axes, fast diagonal scrolling revealed blank areas, and resizing the window left the photo grid with the wrong number of columns until the user scrolled.

Grids need a different technique. The Virtual Lists & Windowing with Observers topic covers the one-dimensional case; this page covers two.

Mechanics Explanation

For a grid with fixed (or column-uniform) cell sizes, the visible range is directly computable:

  • firstRow = floor(scrollTop / rowHeight), lastRow = ceil((scrollTop + viewportHeight) / rowHeight)
  • firstCol = floor(scrollLeft / colWidth), lastCol = ceil((scrollLeft + viewportWidth) / colWidth)

plus overscan on each side. That is O(1) per scroll event, with no DOM reads beyond the scroll position and viewport size.

Sentinel-based windowing works in one dimension because the rendered block has two edges; in two dimensions it has four edges and four corners, and diagonal scrolls cross several at once. Observers also deliver after paint, so a fast scroll shows a frame of empty space before the callback fills it — acceptable at the end of a list, glaring across a spreadsheet.

So the scroll position drives the window — read once per frame via a passive scroll listener coalesced to requestAnimationFrame — and observers do what they are good at:

  • ResizeObserver on the scroller tells you when the viewport size changes: recompute visible counts, overscan, and for responsive photo grids the column count and cell size.
  • IntersectionObserver on the whole grid (viewport root) tells you when the grid is off-screen, so the scroll handler and any animated cells can pause.

The Visible Window in Two AxesA scroller's viewport over a large grid. The rendered block covers the visible rows and columns plus one row and one column of overscan on each side. Cells outside the block are not in the DOM. Sticky row and column headers stay pinned while cells scroll under them.visible rows × visible cols — renderedoverscan ring — rendered, off-screenbeyond overscan — not in the DOMSolid blue frame: scroller viewport. Dashed frame: rootMargin 22px.

Comparison Table: Windowing Techniques by Shape

Shape Visible range from Observer role Notes
1D list, fixed height scroll arithmetic or sentinels sentinels optional either works
1D list, variable height offset index + scroll RO measures rows see variable-height guide
2D grid, fixed cells scroll arithmetic, both axes RO on scroller; IO for off-screen sentinels do not scale
Responsive photo grid scroll arithmetic; columns from width RO recomputes columns cells square or fixed ratio
Masonry per-column offset indexes RO measures cells hardest; consider CSS grid-lanes where available

Minimal Reproducible Example

TypeScript
// Sentinels around the rendered block: breaks down in two axes.
const io = new IntersectionObserver((entries) => {
  for (const e of entries) if (e.isIntersecting) extendTowards(e.target);   // one edge at a time
});
['top', 'bottom', 'left', 'right'].forEach((side) => io.observe(document.querySelector(`.sentinel-${side}`)!));

declare function extendTowards(el: Element): void;

Fling diagonally: the grid extends one side per callback, a frame after paint, and blank regions flash in the corners.

Production-Safe Solution

TypeScript
interface GridSpec { rows: number; cols: number; rowH: number; colW: number; overscan?: number }
type CellRenderer = (el: HTMLElement, r: number, c: number) => void;

export class VirtualGrid {
  #pool: HTMLElement[] = [];
  #live = new Map<string, HTMLElement>();
  #vw = 0; #vh = 0;
  #raf = 0;
  #active = true;

  constructor(private scroller: HTMLElement, private canvas: HTMLElement, private spec: GridSpec, private renderCell: CellRenderer) {
    canvas.style.cssText = `position:relative;inline-size:${spec.cols * spec.colW}px;block-size:${spec.rows * spec.rowH}px`;
    scroller.addEventListener('scroll', () => this.#schedule(), { passive: true });

    new ResizeObserver(([e]) => {                       // viewport size → visible counts
      this.#vw = e.contentBoxSize[0].inlineSize;
      this.#vh = e.contentBoxSize[0].blockSize;
      this.#update();                                   // same frame as the resize
    }).observe(scroller);

    new IntersectionObserver(([e]) => { this.#active = e.isIntersecting; if (this.#active) this.#schedule(); })
      .observe(scroller);
  }

  #schedule(): void {
    if (!this.#active || this.#raf) return;
    this.#raf = requestAnimationFrame(() => { this.#raf = 0; this.#update(); });
  }

  #update(): void {
    const { rows, cols, rowH, colW, overscan = 2 } = this.spec;
    const st = this.scroller.scrollTop, sl = this.scroller.scrollLeft;
    const r0 = Math.max(0, Math.floor(st / rowH) - overscan);
    const r1 = Math.min(rows, Math.ceil((st + this.#vh) / rowH) + overscan);
    const c0 = Math.max(0, Math.floor(sl / colW) - overscan);
    const c1 = Math.min(cols, Math.ceil((sl + this.#vw) / colW) + overscan);

    const wanted = new Set<string>();
    for (let r = r0; r < r1; r++) for (let c = c0; c < c1; c++) wanted.add(`${r}:${c}`);

    for (const [key, el] of this.#live) {               // release cells that left the window
      if (!wanted.has(key)) { this.#live.delete(key); this.#pool.push(el); el.hidden = true; }
    }
    for (const key of wanted) {                          // fill new positions, recycling nodes
      if (this.#live.has(key)) continue;
      const [r, c] = key.split(':').map(Number);
      const el = this.#pool.pop() ?? this.canvas.appendChild(Object.assign(document.createElement('div'), { className: 'cell' }));
      el.hidden = false;
      el.style.transform = `translate(${c * colW}px, ${r * rowH}px)`;
      el.style.inlineSize = `${colW}px`; el.style.blockSize = `${rowH}px`;
      this.renderCell(el, r, c);
      this.#live.set(key, el);
    }
  }
}

The scroll listener only schedules one update per frame; the update reads two numbers and computes ranges, then recycles cells from a pool rather than creating and destroying elements, as described in recycling DOM nodes in observer-driven lists. The ResizeObserver recomputes the window in the same frame as a viewport size change, so no blank edge appears after resizing. The IntersectionObserver stops all work while the grid is off-screen.

For a responsive photo grid, the ResizeObserver also recomputes cols and cell size from the width (for example cols = floor(width / 180), colW = width / cols), updates rows = ceil(count / cols), resizes the canvas and re-renders the window.

Which Mechanism Does What in a Virtual GridFour boxes. A passive scroll listener schedules one update per frame. The update computes the row and column range from scroll position and cell size, with overscan. Cells leaving the range return to a pool and cells entering it are filled from the pool. A ResizeObserver on the scroller recomputes the range and, for responsive grids, the column count; an IntersectionObserver pauses everything while the grid is off-screen.Scroll listener1 update per frameRange arithmeticrows × cols + overscanRecycle cellspool, translate, renderRO / IOresize → recompute;off-screen → pause

Sticky Headers and Accessibility

Spreadsheets need frozen header rows and columns. Render them outside the recycled cell pool: a top header strip with position: sticky; top: 0 containing only the visible columns' headers (windowed on the column axis), and a left strip with position: sticky; left: 0 for visible row headers (windowed on the row axis). The corner cell is sticky in both axes.

Virtual grids are hard for assistive technology because most cells do not exist in the DOM:

  • Use role="grid" with aria-rowcount and aria-colcount for the full dimensions, and aria-rowindex / aria-colindex on rendered cells.
  • Implement keyboard navigation (arrow keys, Page Up/Down, Home/End) by moving the logical active cell and scrolling it into the window, rather than relying on Tab through thousands of cells.
  • Keep the focused cell rendered even when it scrolls out of the overscan ring, so focus is never lost to recycling.

Grid Accessibility AttributesA grid of attributes and where they go. The grid container gets role grid and the full aria-rowcount and aria-colcount. Each rendered row gets aria-rowindex. Each rendered cell gets role gridcell and aria-colindex. The focused cell is pinned so recycling never removes it.AttributeWhyContainerrole=grid, aria-rowcount/colcountannounces full sizeRendered rowaria-rowindextrue position, not DOM positionRendered cellrole=gridcell, aria-colindextrue columnFocused cellnever recycledfocus is not lost

Verification Steps

  • Fling diagonally at speed and confirm no blank regions appear beyond a frame.
  • Resize the window and confirm the window refills immediately, and a responsive grid re-flows its columns.
  • Count DOM nodes while scrolling; it should stay near visible cells plus overscan.
  • Scroll the page so the grid is off-screen and confirm scroll updates stop.
  • Navigate with the keyboard across thousands of rows and confirm focus never disappears.

Common Mistakes to Avoid

  • Sentinels in two dimensions. Four edges and delivery after paint produce blank corners.
  • Creating and destroying cells on every scroll. Recycle from a pool.
  • Reading layout per cell. Cell positions come from arithmetic, not measurement.
  • Letting the focused cell be recycled. Pin it outside the window.

FAQ

Why not use IntersectionObserver for the 2D window?

Because the window has four edges and diagonal scrolls cross several at once, and observers deliver after paint, which shows blank space for a frame on fast scrolls. Scroll-position arithmetic, done once per frame, is simpler and immediate.

Isn't a scroll listener what observers were meant to replace?

For visibility questions, yes. The window of a virtual grid is a position question — which cells are at this scroll offset — which is exactly what scroll position answers. One cheap read per frame is fine.

How much overscan should a grid have?

One or two rows and columns is usually enough with per-frame updates. Increase it if cells are expensive to render and flings reveal blanks on slow devices.

Can cells have variable sizes?

Variable column widths with uniform row heights are manageable with a prefix-sum array per axis. Fully variable cell sizes in both axes, as in masonry, need per-column offset indexes and measurement, which is considerably more complex.

Should cells use transform or top/left?

Transform. It avoids layout for positioning and lets the browser composite cells efficiently while scrolling.

How do I support text selection across cells?

Native selection across recycled cells is unreliable. Spreadsheets implement their own range selection model, rendering highlights on the visible cells from logical selection state.


↑ Back to Virtual Lists & Windowing with Observers