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.
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.
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 = 4803Render 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.
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
Change the overscan
In the fixed-height list set
OVERSCANto 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
Remove the header correction
Delete
- HEADER_Hfrom thelastformula. Which count changes, and why is the extra row wasted work? (It sits under the sticky header, so nobody can see it.) - 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 correctscrollTopby hand? (Not every browser supports it, and absolutely positioned rows confuse it.) - 4
Measure with a smaller estimate
In the variable-height list change
ESTIMATEto 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
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
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 #10000Every 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
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 80187pxThe same range maths runs twice, for rows (scrollTop) and columns (scrollLeft). Only the cells where both ranges meet are rendered.
Preview
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 C105Both 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
Console
Nothing logged yet.Expected console output
Before: plain shows Message 105, anchored shows Message 105
After: plain shows Message 85, anchored shows Message 105import { 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" })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.
Break #2
Measure in useEffect instead of useLayoutEffect
In the variable-height list, change useLayoutEffect to useEffect.
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.
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
scrollTopwhen an item above the viewport changes size (TanStack Virtual'sshouldAdjustScrollPositionOnItemSizeChangelets 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
transformso 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"orrole="table"witharia-rowcount, wrap cells inrole="row"elements witharia-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
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
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 (toportransform: translateY(index × rowHeight)). Every scroll event updatesscrollTopin state, React renders again, and the slice moves. - 3
The scroll maths for fixed heights. With every row
hpixels tall, the row at the top edge isMath.floor(scrollTop / h)and the row at the bottom edge isMath.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 to0andcount - 1. Each row's position isindex × 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
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 (
offsetHeightinuseLayoutEffect, or aResizeObserverso 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 rowi. Finding the first visible row is then a binary search inoffsetsforscrollTop(see /dsa, binary search), O(log n) instead of walking the whole list. - 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 toscrollTop. Browsers have a built-in version called scroll anchoring (the CSS propertyoverflow-anchor), but it isn't in every browser and virtual lists usually manage the position themselves, so they turn it off and correctscrollTopby hand. - 6
Grids and accessibility. A virtual grid does the same maths twice: once for rows with
scrollTop, once for columns withscrollLeft, 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 rowaria-setsize(the total) andaria-posinset(its 1-based position); on a table or grid usearia-rowcounton the container andaria-rowindexon eachrole="row". Make the scroll box focusable (tabIndex={0}plus anaria-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
Explain how a virtualised list decides which rows to render, with the formula for fixed-height rows.
How do you virtualise rows whose heights aren't known in advance?
What is overscan and how do you choose it?
A chat loads older messages at the top and the user's position jumps. Why, and how do you fix it?
What do you lose by virtualising, and how do you reduce each cost?
Practice
Rows are 50px, the viewport is 400px, there's no header and overscan is 2. Which indexes render when scrollTop is 1,025?
Add a "Jump to row 5000" button to the fixed-height list.
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.