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:
ResizeObserveron the scroller tells you when the viewport size changes: recompute visible counts, overscan, and for responsive photo grids the column count and cell size.IntersectionObserveron the whole grid (viewport root) tells you when the grid is off-screen, so the scroll handler and any animated cells can pause.
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
// 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
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.
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"witharia-rowcountandaria-colcountfor the full dimensions, andaria-rowindex/aria-colindexon 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.
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.
Related
- Building a Windowed Virtual List with IntersectionObserver — the one-dimensional case
- Measuring Variable-Height Rows with ResizeObserver — variable sizes along one axis
- Keeping Keyboard Focus Stable When Content Loads — focus under recycling
↑ Back to Virtual Lists & Windowing with Observers