Every list-virtualization explainer stops at the same sentence: "it only renders what's visible." True, but it never shows you the mechanism — the DOM shrinking, the index math, the scrollbar staying honest. So I built one where you can watch all of it move as you scroll.
▶ Live demo: https://virtualized-list-drab.vercel.app/
Source (React 19 + TS): https://github.com/dev48v/virtualized-list
Flip between Windowed and Naive (render all) on a 50,000-row list. The panel on the right shows the real DOM node count (measured from the tree, not claimed), the windowing math recomputed live from scrollTop, and a main-thread frame meter.
The whole idea in five lines
const startIndex = Math.floor(scrollTop / rowHeight);
const visibleCount = Math.ceil(viewportHeight / rowHeight);
const start = Math.max(0, startIndex - overscan);
const end = Math.min(total, startIndex + visibleCount + overscan);
const offsetY = start * rowHeight;
You only ever build DOM for rows [start, end) — about 15 of them for a 440px viewport. As scrollTop changes, that window slides and React reconciles ~15 rows instead of 50,000.
Two tricks make it actually work
1. A spacer keeps the scrollbar honest. If you only render 15 rows, the scroll container is only 15 rows tall and there's nothing to scroll. So you put the rendered slice inside a wrapper whose height is the full total × rowHeight (2,000,000px for 50k rows). The browser gives you a real, correctly-sized scrollbar for content that isn't there.
2. A transform puts the slice where it belongs. The 15 mounted rows would naturally render at the top. You push them down to the scroll position with translateY(offsetY) so row #200 appears exactly where row #200 should be.
<div style={{ height: total * rowHeight, position: "relative" }}>
<div style={{ transform: `translateY(${offsetY}px)` }}>
{rows.slice(start, end).map(i => <Row key={i} i={i} />)}
</div>
</div>
Overscan: the anti-flicker knob
If you render exactly the visible rows, fast scrolling shows a blank strip for a frame before React commits the new window. Overscan renders a few extra rows above and below the viewport as a buffer. The demo has a slider — set it to 0 and scroll fast to see the trade-off (more overscan = smoother, but more nodes).
Why it matters: feel the jank
Switch to Naive on 50,000 rows and scroll. The "worst frame" number jumps well past 16.7ms (one 60fps frame) — that's dropped frames you can feel under your finger. Every one of those 50,000 rows is a layout, paint, and reconciliation cost the browser pays whether you can see the row or not. Windowing pays for ~15.
The whole thing is hand-rolled — no react-window, no @tanstack/react-virtual — precisely so the mechanism is readable. In production you'd reach for those libraries (they handle variable row heights, horizontal windowing, grids), but the core is exactly these five lines.
If this made windowing click, a star helps others find it: https://github.com/dev48v/virtualized-list
Top comments (0)