Command Palette

Search for a command to run...

Hectal
PHASE 24Advanced ~40 min· topic 1 of 10

Topic 24.1

Virtualised Lists and Grids

In one line

A virtualised list keeps thousands of items scrollable while only the 10 to 20 rows you can see exist in the page: it works out the visible slice from the scroll position, positions those rows inside a full-height spacer, and measures rows whose height it can't know in advance.

Think of it like this

A lift with a small window in its door. The building has 100 floors, but through the window you only ever see one or two of them as you ride past. Nobody builds a copy of every floor inside the lift. A virtualised list is that window: the list has 10,000 rows, but only the handful in view are really built, and as you scroll the page swaps them for the next ones.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Virtualisation (windowing)
Rendering only the rows inside the visible area of a scroll box, plus a few extra, and swapping them as you scroll.
Viewport
The visible part of the scroll container: the rows you can see right now.
scrollTop
How many pixels a scroll container has been scrolled down from the top.
Spacer
One empty element as tall as the whole list, so the scrollbar has the right size.
Overscan
Extra rows rendered just outside the viewport so fast scrolling doesn't show blank space.
Prefix sums (offsets)
A running total of row heights: entry i is where row i starts.
Estimate
A guessed row height used until the row has been drawn and measured.
Scroll anchoring
Keeping what the user is reading in the same place on screen when content above it changes.
aria-setsize / aria-posinset
Attributes that tell a screen reader the list's real length and an item's position in it.

Step by step

01Draw the three layers

A virtual list is a box with a window. The scroll container is the window; the spacer is the full, invisible height of the list; the rendered rows sit on the spacer at their real positions.

Draw the three layersdiagram
Rendering diagram…

02Work out the visible slice

Read scrollTop from the scroll event, divide by the row height, add overscan and clamp. With rows of 30px, a 200px box, a 30px sticky header and overscan 3, scrolling to 1200px gives rows 37 to 48 (indexes), which is 12 rows.

range.jswhole filejavascript
const first = Math.max(0, Math.floor(scrollTop / ROW_H) - OVERSCAN);
// 1200 / 30 = 40, minus 3 = 37
const last = Math.min(count - 1, Math.floor((scrollTop + VIEW_H - HEADER_H) / ROW_H) + OVERSCAN);
// (1200 + 200 - 30) / 30 = 45.6, floor 45, plus 3 = 48

03Render only those rows

Loop from first to last and position each row with transform: translateY(index × ROW_H px). A transform moves the row without the browser recomputing the layout of its neighbours. The key is the item's id (here the index is the identity, because the list never reorders).

04Variable heights: estimate, measure, correct

Unknown heights start as an estimate. After each commit, measure the rows on screen; if any size changed, store it and render again. Offsets are rebuilt from the sizes, and a binary search finds the first visible row.

Variable heights: estimate, measure, correctdiagram
Rendering diagram…

05Keep the reader's place when content is added above

Remember scrollHeight before the change. In useLayoutEffect after the change, add the difference to scrollTop. The same idea fixes jumps when a row above the viewport is measured taller than its estimate.

06Reach for a library on real projects

TanStack Virtual (@tanstack/react-virtual) is a headless hook: it does the maths, measuring and scroll-to-index, and you render the markup. react-window gives ready components. Both use exactly the mechanism you just built.

Try it yourself

  1. 1

    Change the overscan

    In the fixed-height list set OVERSCAN to 0, then 10. Predict the three counts before you run. (With 0 you get 6 rows in all three lines; work out the 10 case from the formula.) Scroll fast by hand with 0 and watch for blank flashes.

  2. 2

    Remove the header correction

    Delete - HEADER_H from the last formula. Which count changes, and why is the extra row wasted work? (It sits under the sticky header, so nobody can see it.)

  3. 3

    Turn the browser's anchoring back on

    In the anchoring example, delete overflow-anchor: none; from the CSS and run. In Chromium the plain box now keeps its place too, because the browser anchors by itself. Why would a virtual list still correct scrollTop by hand? (Not every browser supports it, and absolutely positioned rows confuse it.)

  4. 4

    Measure with a smaller estimate

    In the variable-height list change ESTIMATE to 20. Predict: does the total height start smaller? Does the second log still find a row near the same place? Estimates far from reality make the scrollbar size and the first jump less accurate.

Code & diagrams

A fixed-height virtual list with a sticky header New tab

The scrollbar is the size of 10,000 rows, but only 9 to 12 rows ever exist. At the top there's no overscan above row 1; at the bottom the browser clamps `scrollTop` to the real maximum. Scroll it yourself and inspect the DOM.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Top: rendered rows 9 of 10000, first Order #1, last Order #9
scrollTop 1200: rendered rows 12 of 10000, first Order #38, last Order #49
Bottom: rendered rows 9 of 10000, first Order #9992, last Order #10000
Variable heights: estimate, measure and binary search React 19+ New tab

Every row starts as a 40px estimate (2000 × 40 = 80,000px). Measured rows are 31, 51 or 71px, so the total grows as more rows are measured. The row ref uses a React 19 ref cleanup function to forget rows that leave the screen.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Top: 7 rows rendered, first Message 1, line 1, total height 80057px
scrollTop 3000: 8 rows rendered, first Message 72, line 1, total height 80187px
A virtual grid: a million cells, about 50 in the DOM New tab

The same range maths runs twice, for rows (scrollTop) and columns (scrollLeft). Only the cells where both ranges meet are rendered.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Start: 40 of 1000000 cells, R1 C1 to R8 C5
Scrolled: 54 of 1000000 cells, R500 C100 to R508 C105
Keeping the scroll anchored when older items load New tab

Both boxes turn off the browser's own anchoring (`overflow-anchor: none`) to show the raw problem. The left one keeps the same `scrollTop`, so new rows push the reader's message out of view. The right one adds the height that appeared above.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Before: plain shows Message 105, anchored shows Message 105
After:  plain shows Message 85, anchored shows Message 105
The same list with TanStack Virtual (not live: install @tanstack/react-virtual)jsx
import { useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";

export function Messages({ messages }) {
  const parentRef = useRef(null);
  const virtualizer = useVirtualizer({
    count: messages.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 40,   // a guess; real sizes are measured
    overscan: 5,
  });

  return (
    <div ref={parentRef} style={{ height: 400, overflowY: "auto" }}>
      <div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
        {virtualizer.getVirtualItems().map((item) => (
          <div
            key={item.key}
            data-index={item.index}            // tells measureElement which row this is
            ref={virtualizer.measureElement}   // measures real height (ResizeObserver)
            style={{ position: "absolute", top: 0, left: 0, width: "100%", transform: "translateY(" + item.start + "px)" }}
          >
            {messages[item.index].text}
          </div>
        ))}
      </div>
    </div>
  );
}

// Jump to a row: virtualizer.scrollToIndex(500, { align: "start" })
react-window 2: ready-made List component (not live: install react-window)jsx
import { List } from "react-window";

function Row({ index, style, names }) {
  // style holds the absolute position: always apply it.
  return <div style={style}>{names[index]}</div>;
}

export function Names({ names }) {
  return (
    <List
      rowComponent={Row}
      rowCount={names.length}
      rowHeight={30}
      rowProps={{ names }}
      style={{ height: 400 }}
    />
  );
}

// react-window 1.x (older code) used <FixedSizeList> and <VariableSizeList>
// with height, itemCount, itemSize and a render-function child.

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Forget the spacer

In the fixed-height list, remove style={{ height: items.length * ROW_H }} from the spacer div.

terminal
$ Preview
── what you'll see ──
The box shows only the first few rows and has no scrollbar. Scrolling is impossible, so the later rows never appear.

Break #2

Measure in useEffect instead of useLayoutEffect

In the variable-height list, change useLayoutEffect to useEffect.

terminal
$ Preview
── what you'll see ──
When you scroll quickly, rows briefly overlap or leave gaps, then snap into place a frame later.

Break #3

Keep row state inside the row

Give each row its own useState for an "expanded" toggle, expand row 3, scroll far down and back up.

terminal
$ Preview
── what you'll see ──
Row 3 is collapsed again.

Myth vs fact

Myth

Virtualisation is a React feature.

Fact

React has nothing built in. It's a pattern you build (or import) on top of scroll events and absolute positioning. It works the same in any UI library.

Myth

With virtualisation any number of rows is free.

Fact

Rendering becomes cheap, but the data still lives in memory, sorting and filtering it still costs, and offsets for variable heights cost O(n) to build. A million rows of data may need paging from the server too.

Myth

Screen readers can read a virtual list like a normal one.

Fact

They only see the rows that exist. Without aria-setsize/aria-posinset (or aria-rowcount/aria-rowindex) they announce a short list with the wrong length.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Production virtualisers avoid rebuilding all offsets on each render: they keep a measurement cache keyed by item key (so sizes survive reordering and filtering), update prefix sums lazily from the first changed index, and correct scrollTop when an item above the viewport changes size (TanStack Virtual's shouldAdjustScrollPositionOnItemSizeChange lets you control this).

  • ▸

    Browsers cap element height (Chromium at roughly 33 million pixels, other engines lower). A list of 2 million rows × 30px exceeds it, so the spacer can't be that tall. Very large lists scale the scroll position (a virtual scroll range mapped onto a smaller real one) or page data from the server instead.

  • ▸

    Scroll events fire at up to once per frame. Rendering ~15 rows per event is cheap, but heavy rows can still drop frames: keep rows memoised, and move rows with transform so moving them doesn't trigger layout. useDeferredValue (Topic 16.7) is the wrong tool here: a lagging scroll position shows blank space.

  • ▸

    Accessibility for virtual tables: use role="grid" or role="table" with aria-rowcount, wrap cells in role="row" elements with aria-rowindex, and keep keyboard focus on a row that stays mounted (the focused row can be pinned in the rendered range even when it's scrolled away). The listbox and grid patterns from Phase 18 apply.

Remember this

  1. 1

    Why it's needed. Topic 16.9 measured the cost of a big list: every row costs React work (calling and reconciling the row component), DOM nodes (elements the browser must keep in memory) and layout and paint (the browser working out where everything goes and drawing it). With 10,000 rows that is tens of thousands of nodes and a slow first render. Virtualisation (also called windowing) makes the cost depend on the size of the viewport (the visible part of the scroll box) instead of the length of the list.

  2. 2

    The three pieces. (1) A scroll container: an element with a fixed height and overflow: auto, so it scrolls on its own. (2) A spacer: one empty element inside it, as tall as all the rows together (count × rowHeight), so the scrollbar has the honest size and scrolling feels normal. (3) The visible rows: absolutely positioned inside the spacer at their real offset (top or transform: translateY(index × rowHeight)). Every scroll event updates scrollTop in state, React renders again, and the slice moves.

  3. 3

    The scroll maths for fixed heights. With every row h pixels tall, the row at the top edge is Math.floor(scrollTop / h) and the row at the bottom edge is Math.floor((scrollTop + viewHeight) / h). Then add overscan: a few extra rows above and below (3 is common), so a fast flick shows real rows instead of a blank flash before React catches up. Clamp to 0 and count - 1. Each row's position is index × h. A sticky header inside the scroll box (position: sticky; top: 0) covers the top part, so subtract its height from the visible area.

  4. 4

    Variable heights need measuring. When rows are chat messages or cards, you can't know a row's height until the browser has drawn it. The usual approach: start with an estimate for every row (say 40px), render the visible ones, measure them after commit (offsetHeight in useLayoutEffect, or a ResizeObserver so later changes such as an image loading are caught), store the real sizes and render again. Row positions come from prefix sums: offsets[i] is the total height of all rows before row i. Finding the first visible row is then a binary search in offsets for scrollTop (see /dsa, binary search), O(log n) instead of walking the whole list.

  5. 5

    Keeping the scroll position anchored. If something above what the user is reading grows (older chat messages are loaded, a row above is measured taller than its estimate), every row below moves down and the reader's place jumps. The fix: in useLayoutEffect (after the DOM changed, before paint) add the height that appeared above to scrollTop. Browsers have a built-in version called scroll anchoring (the CSS property overflow-anchor), but it isn't in every browser and virtual lists usually manage the position themselves, so they turn it off and correct scrollTop by hand.

  6. 6

    Grids and accessibility. A virtual grid does the same maths twice: once for rows with scrollTop, once for columns with scrollLeft, and renders only the cells where both ranges meet. The price of virtualisation: rows that aren't rendered don't exist, so find-in-page (Ctrl+F) can't find them, and a screen reader can't count them. Tell assistive technology the real size: on a list give each row aria-setsize (the total) and aria-posinset (its 1-based position); on a table or grid use aria-rowcount on the container and aria-rowindex on each role="row". Make the scroll box focusable (tabIndex={0} plus an aria-label) so keyboard users can scroll it with the arrow keys. Unmounted rows also lose their state, so keep row state (selection, expanded) above the list, keyed by id.

Explain it without notes

01

Explain how a virtualised list decides which rows to render, with the formula for fixed-height rows.

02

How do you virtualise rows whose heights aren't known in advance?

03

What is overscan and how do you choose it?

04

A chat loads older messages at the top and the user's position jumps. Why, and how do you fix it?

05

What do you lose by virtualising, and how do you reduce each cost?

Practice

01

Rows are 50px, the viewport is 400px, there's no header and overscan is 2. Which indexes render when scrollTop is 1,025?

02

Add a "Jump to row 5000" button to the fixed-height list.

03

Make the fixed-height list keep a selected row highlighted after it scrolls out and back in.

Trade-offs

  • ↔

    Hand-written virtualisation is ~40 lines for fixed heights and teaches the mechanism, but variable heights, scroll-to-index, horizontal lists, grids, sticky rows and smooth anchoring add up fast. On real projects TanStack Virtual (headless, flexible) or react-window (ready components, small) is cheaper to maintain.

  • ↔

    Virtualise only when a list is big enough to hurt (measure: hundreds of complex rows or thousands of simple ones). Pagination or content-visibility: auto (Topic 16.9) keeps find-in-page and accessibility for free.

Done when you can

  • I can build a fixed-height virtual list with a spacer, overscan and a sticky header.

  • I can compute the visible range for any scrollTop by hand.

  • I can virtualise variable-height rows with estimates, measurement and binary search over offsets.

  • I can keep the reader's place when items are added above.

  • I can make a virtual list accessible with ARIA size and position attributes.

  • I know when to use TanStack Virtual or react-window instead of my own code.