Render a list of ten thousand rows the obvious way and the browser will do exactly what you asked: create ten thousand elements, lay out ten thousand elements, paint ten thousand elements, and hold all of them in memory for as long as the page is open. The first paint takes a visible beat. Scrolling stutters. Adding a search box makes it worse, because now every keystroke rebuilds all ten thousand. And the whole time, the user is looking at about nine of them.
Virtual scrolling fixes this by rendering only what fits on screen while making the scrollbar behave as though the entire list were there. It's the technique behind every fast data grid, log viewer, chat history and file browser you've used, and it's the reason react-window, TanStack Virtual and their equivalents exist in every framework ecosystem. It's also — and this is the part worth internalizing — about forty lines of arithmetic. There's no clever data structure and no framework magic. There's a fixed row height, a division, and a small pool of recycled DOM nodes.
The Virtual Scroll List snippet is a good subject for a full walkthrough because it doesn't stop at the toy version. It has a live search that filters the dataset and resizes the scrollbar to match, and a sort toggle that reorders ten thousand records instantly — both of which are where naive implementations fall apart. Here it is live: scroll it hard, search it, sort it, and if you have devtools open, watch the row count in the inspector sit in the teens the whole time.
Grab the code, or open the full editor with live HTML/CSS/JS panels: Virtual Scroll List on FWD Tools. It's plain HTML, CSS and JavaScript with zero dependencies — about 120 lines of JavaScript including the fake data — and the same editor has one-click export buttons for React, Vue, Angular and React + Tailwind.
In this post I'll build the whole thing A to Z: the three structural pieces and why each one exists, the index math that turns a scroll offset into an array slice, why there's a buffer and how big it should be, how the row pool grows and shrinks instead of being rebuilt, why the scroll handler is throttled to one render per frame, and how search and sort stay instant on ten thousand records. By the end you'll be able to write this from scratch — and, more usefully, know exactly when it's the wrong tool.
What the component actually is
Four ideas, in the order they matter:
- A spacer — an empty element whose height equals the full list's height, so the scrollbar is correctly sized even though the rows aren't there.
- Index math that converts
scrollTopinto "which slice of the array is currently on screen". - A row pool — a dozen or two real DOM elements that get repositioned and refilled instead of created and destroyed.
- A single render function that every input — scrolling, searching, sorting — calls, so there is exactly one code path that decides what's visible.
Nothing here is specific to a user directory. Swap the row markup and the same four pieces window a log stream, a transaction table, or a file listing.
Where you'd actually use this
- Data grids and admin tables. The moment a query can return "all results", you're one careless filter away from rendering a table nobody can scroll.
- Log and event viewers. These are append-heavy and unbounded by nature — a log viewer without windowing is a memory leak with a scrollbar.
- Chat and message histories. A long thread is the canonical case, though it's also the hardest variant, since message heights vary.
- Contact lists, file browsers, autocomplete dropdowns. Anywhere a search can match thousands of items and you want the results to appear instantly instead of "instantly, after a 400ms layout".
- Understanding what your framework's virtualizer is doing.
react-windowis a hundred lines of the same idea plus edge cases. Having written it by hand once makes its props —itemSize,overscanCount,estimatedItemSize— read as decisions rather than incantations.
The markup and the three CSS rules that matter
The structure is deliberately minimal. Everything above the scroll container is chrome — a heading, a search input, a sort button, a stats row:
<div class="scroll-container" id="scrollContainer">
<div id="spacer" class="spacer"></div>
<div id="listItems" class="list-items"></div>
</div>
Two children, both empty in the HTML. The spacer never receives content — its only job is to be tall. The listItems container holds the row pool. And three CSS rules make that arrangement work:
.scroll-container { flex: 1; overflow-y: auto; position: relative; }
.spacer { width: 100%; }
.list-items { position: absolute; top: 0; left: 0; right: 0; }
overflow-y: auto gives the container its own scrollbar, and position: relative makes it the containing block for what follows. The spacer is a normal-flow element with no content, so the only thing that gives it height is the JavaScript that sets it — and that height is what the scrollbar measures. .list-items is absolutely positioned at the container's origin, which takes it out of flow so it doesn't add to the scroll height, while still scrolling along with the content. The rows inside it are absolutely positioned too, each at its own top.
That's the whole illusion: a tall empty box producing the scrollbar, and a floating layer of real rows positioned over it at the right offsets.
Building it, step by step
Step 1 — The three constants
const ITEM_HEIGHT = 64;
const BUFFER = 4;
const TOTAL = 10000;
ITEM_HEIGHT is the one that carries the whole technique. Because every row is exactly 64 pixels tall, any row's vertical position is index * 64 and any scroll offset converts back to an index with a single division. Every piece of math below is a consequence of that constant being true. If it stops being true — if a row wraps to two lines — the arithmetic doesn't error, it just silently starts putting rows in the wrong place.
BUFFER is how many extra rows to render above and below the visible window. TOTAL is just the size of the demo dataset.
Step 2 — Generating ten thousand records
function hue(i) { return (i * 137.5) % 360; }
const allData = Array.from({length: TOTAL}, (_, i) => {
const firstName = rand(FIRST_NAMES);
const lastName = rand(LAST_NAMES);
return {
id: i+1,
name: firstName + ' ' + lastName,
role: rand(ROLES),
hue: hue(i),
initials: firstName[0] + lastName[0],
status: rand(STATUSES),
};
});
Ten thousand plain objects, built once at startup. This is worth noting because it's the part that isn't optimized: the data is all in memory, all the time. Virtual scrolling is a rendering optimization, not a data-loading one — if ten thousand records is too much to hold, you want pagination or incremental fetching, and that's a different technique that composes with this one instead of replacing it.
The hue() function is a small nice touch: multiplying the index by 137.5 degrees — the golden angle — and wrapping at 360 produces avatar colors that never repeat consecutively and never clump, which is the same trick used to generate distinct series colors in charts. The STATUSES array is weighted by repetition (['online','online','online','away','offline','offline']), so a plain random pick lands on "online" half the time without any probability code.
Step 3 — Sizing the spacer
function updateSpacer() {
spacer.style.height = (displayData.length * ITEM_HEIGHT) + 'px';
}
One line, and it's the entire reason the scrollbar feels honest. Ten thousand rows at 64px is a 640,000-pixel-tall element — the scroll thumb becomes a sliver, scrolling to 50% lands you at row 5,000, and a flick of the trackpad travels the same distance it would in a real list.
Note that it measures displayData, not allData. That's the hook that makes search work correctly later: filter down to twelve results and the spacer collapses to 768px — barely more than a screenful — so the scrollbar thumb grows to fill almost the whole track. A virtual list that filters its rows but forgets to resize its spacer is the most common bug in hand-rolled implementations — you get a scrollbar promising thousands of rows and an ocean of blank space below the twelve real ones.
Step 4 — The visible window math
This is the core of the whole technique:
const scrollTop = scrollContainer.scrollTop;
const containerH = scrollContainer.clientHeight;
const visibleCount = Math.ceil(containerH / ITEM_HEIGHT);
let startIdx = Math.floor(scrollTop / ITEM_HEIGHT) - BUFFER;
let endIdx = startIdx + visibleCount + BUFFER * 2;
startIdx = Math.max(0, startIdx);
endIdx = Math.min(displayData.length, endIdx);
Line by line. Math.floor(scrollTop / ITEM_HEIGHT) is how many whole rows have scrolled past the top edge — at scrollTop = 1000 with 64px rows, that's row 15. Subtracting BUFFER starts the render four rows earlier than strictly necessary. Math.ceil(containerH / ITEM_HEIGHT) is how many rows fit in the viewport, rounded up because a partially visible row still has to be drawn. The end index is the start plus that count plus BUFFER * 2 — one buffer's worth for the rows we backed up over, one for the rows below the fold.
The clamps come last, and the order is worth understanding instead of copying blindly. Because startIdx is clamped to 0 after endIdx has been computed from it, the window at the very top of the list is naturally four rows shorter — there are no rows above index 0 to buffer, and those four rows are not reallocated to the bottom instead. Clamp first and you would simply render four more rows than you need while sitting at the top. Nothing breaks either way; this order just keeps the window the size the arithmetic says it should be.
Plug in real numbers: a 520px-tall container gives visibleCount = 9, so mid-list the window is 9 + 8 = 17 rows. Seventeen elements, whether the array holds ten thousand entries or ten million. That's the entire performance claim, and it's why the cost of this component is bounded by your viewport rather than your data.
Why buffer at all? Because rendering is asynchronous relative to scrolling. A fast fling can move the viewport further than the last render accounted for before the next frame lands, and without spare rows above and below you'd see a strip of empty background flash into view at the leading edge. Four rows is a reasonable default; the cost of raising it is linear (more DOM nodes) and the benefit tops out quickly.
Step 5 — The row pool
const needed = endIdx - startIdx;
while (listItems.children.length < needed) {
const row = document.createElement('div');
row.className = 'list-row';
row.innerHTML = `<div class="avatar"></div><div class="user-info"><div class="user-name"></div><div class="user-role"></div></div><div class="status-dot"></div><div class="row-num"></div>`;
listItems.appendChild(row);
}
while (listItems.children.length > needed) {
listItems.removeChild(listItems.lastChild);
}
Two while loops that converge the pool size on needed. Note what they don't do: they never clear the container and rebuild it. The obvious implementation — listItems.innerHTML = '' followed by building 17 fresh rows — would run on every single scroll frame, which means creating and discarding a thousand elements a second, generating constant garbage-collection pressure and throwing away the browser's ability to reuse layout work.
In practice these loops barely run. They fill the pool on the first render, and then adjust it only at the two ends of the list, where the window is clipped — at the very top there are no rows above index 0 to buffer, so the pool is four rows shorter, and it grows by four as you scroll into the body of the list. Through ordinary mid-list scrolling both conditions are false immediately and neither loop does anything. The other times they fire are a container resize and a search that narrows the results to fewer rows than fit on screen. The empty innerHTML skeleton is written once per element, at creation — from then on the row is a fixed structure whose leaves get their text rewritten.
Step 6 — Writing the rows
for (let i = 0; i < needed; i++) {
const dataIdx = startIdx + i;
const item = displayData[dataIdx];
const row = listItems.children[i];
const top = dataIdx * ITEM_HEIGHT;
row.style.top = top + 'px';
row.style.height = ITEM_HEIGHT + 'px';
const avatar = row.querySelector('.avatar');
avatar.textContent = item.initials;
avatar.style.background = `hsl(${item.hue}, 55%, 40%)`;
row.querySelector('.user-name').textContent = item.name;
row.querySelector('.user-role').textContent = item.role + ' #' + item.id;
const dot = row.querySelector('.status-dot');
dot.className = 'status-dot ' + item.status;
row.querySelector('.row-num').textContent = '#' + item.id;
}
Two indices are in play and keeping them straight is the whole job. i is the position in the pool — 0 through 16, the physical element. dataIdx is the position in the array — 4,213, say. The pool element at position 3 might be showing record 4,216 now and record 8,902 a moment later; it is not "the row for record 4,216", it's just a row.
The line that ties them together is row.style.top = dataIdx * ITEM_HEIGHT. The element's visual position is derived from the data index, not the pool index, which is what places it at its true position in the full ten-thousand-row column even though only seventeen elements exist. Everything else is content assignment.
All the writes use textContent rather than innerHTML. That's faster — no HTML parsing — and it's also the correct default for anything user-supplied, since textContent can't be tricked into executing markup. The one structural write is dot.className = 'status-dot ' + item.status, which replaces the whole class list rather than adding to it; that matters here, because a recycled row still carries the previous record's status class and adding without clearing would leave a green dot on an offline user.
One small oddity in the original: row.style.height is set on every pass even though it's always the same constant. It's harmless — assigning an identical value doesn't invalidate layout — but it's the kind of line that belongs in the pool-creation loop, not the render loop. (There's also a const rows = listItems.children above the loops that nothing ever reads; the code uses listItems.children directly. Both are leftovers, and worth spotting as a reminder that "it's in the render loop" doesn't mean "it needs to be".)
Step 7 — One render per frame
function scheduleRender() {
if (rafPending) return;
rafPending = true;
requestAnimationFrame(() => { render(); rafPending = false; });
}
scrollContainer.addEventListener('scroll', scheduleRender);
Scroll events fire whenever the browser feels like it — often several times between two paints, and on some input devices far more. Since the screen can only show one state per frame, any render beyond the first in a frame is work whose output is immediately overwritten.
The pattern here is a "leading-edge coalescer": the first scroll event of a frame schedules a render and raises a flag; every subsequent event returns immediately; the callback renders once and lowers the flag. Note that the render runs inside the requestAnimationFrame callback, which places it in the browser's frame just before layout and paint — so the DOM writes land at the moment the browser is about to lay out anyway, instead of mid-frame where they might force an extra reflow.
It's worth being clear about what this does and doesn't fix. It doesn't make scrolling smooth on its own — the render still happens on the main thread, and at 60fps you have about 16ms for it. What it guarantees is that you never do that work twice for the same visual result, which is often the difference between comfortably inside the frame budget and just outside it.
Step 8 — Search that filters ten thousand records instantly
let searchTimeout = null;
document.getElementById('searchInput').addEventListener('input', e => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
const q = e.target.value.trim().toLowerCase();
displayData = q ? allData.filter(d => d.name.toLowerCase().includes(q) || d.role.toLowerCase().includes(q)) : allData;
const n = displayData.length;
countLabel.textContent = n.toLocaleString() + ' of 10,000 items';
scrollContainer.scrollTop = 0;
updateSpacer();
render();
}, 150);
});
Four things happen in a deliberate order, and each one matters.
The 150ms debounce. clearTimeout then setTimeout is the standard shape: each keystroke cancels the pending filter and schedules a new one, so typing "engineer" runs one filter instead of eight. 150ms is short enough to feel immediate and long enough to cover normal typing cadence.
Filtering rebuilds displayData from allData, always. Filtering the already-filtered array would be faster, but it would also make the search one-way — deleting a character could never widen the results. Re-deriving from the source every time is the version that's correct rather than the one that's clever.
Reset the scroll before resizing the spacer. If you were at row 8,000 and the search returns twelve matches, a scrollTop of half a million pixels is now past the end of a much shorter list. Setting it to 0 first means the browser never has to reconcile an out-of-range scroll position against a shrinking scroll height.
Then updateSpacer() and render(). The spacer resizes the scrollbar to the result count; the render repaints the window from the new array. The row pool doesn't change at all unless the results are fewer than a screenful.
And this is where the design pays off: filtering ten thousand objects with Array.filter and two includes calls takes a millisecond or two. It feels instant because the DOM work didn't change — it's still seventeen rows, exactly as it was before you typed. The naive version's search feels slow not because filtering is slow, but because it's followed by rebuilding ten thousand elements.
Step 9 — Sort
document.getElementById('sortBtn').addEventListener('click', () => {
sortDir *= -1;
displayData = [...displayData].sort((a,b) => a.name.localeCompare(b.name) * sortDir);
document.getElementById('sortBtn').childNodes[2].textContent = sortDir === 1 ? ' A–Z' : ' Z–A';
scrollContainer.scrollTop = 0;
render();
});
sortDir flips between 1 and -1, and multiplying the comparator's result by it inverts the ordering without a second comparator — a neat trick that generalizes to any comparison function. The array is copied with a spread before sorting because Array.sort mutates in place, and mutating displayData while it might be pointing at allData would permanently scramble the source data. localeCompare rather than < is the right call for names, since it handles accented characters in the order a human would expect.
Notice what's absent: no updateSpacer(). Sorting reorders the array without changing its length, so the total height — and therefore the scrollbar — is unchanged. Only the contents of the visible window differ, so a single render() is the entire repaint. Sorting ten thousand records is a few milliseconds of pure array work followed by rewriting seventeen rows' worth of text.
The childNodes[2] is the one genuinely fragile line in the file: the button contains a text node, an inline SVG icon, and then the label text, so index 2 is the label. It works, and it will keep working right up until someone reformats the markup. A <span> around the label with a proper query selector would cost nothing and survive editing.
Customizing it for your own project
- Use
transforminstead oftop. Writingrow.style.transform = 'translateY(' + top + 'px)'moves positioning off the layout path and onto the compositor. At seventeen rows the difference is academic; with a larger buffer or heavier row content it's measurable. - Make the row height a CSS variable. Right now 64 lives in both the JavaScript constant and the CSS. Define it once as a custom property and read it back with
getComputedStyle, so a design tweak can't silently desynchronize the math from the layout. - Recompute on resize.
visibleCountis derived fromclientHeighton every render, so it's already correct — but nothing triggers a render when the window resizes without scrolling. AResizeObserveron the container callingscheduleRendercloses that gap in two lines. - Add horizontal virtualization for wide tables. The same math applied to
scrollLeftand a column width windows the columns as well as the rows, which is what makes hundred-column spreadsheets viable. - Sticky group headers. Because you know the index of every row on screen, you also know which group the top row belongs to — render one extra absolutely-positioned header pinned to the top of the viewport and you have grouped sections without breaking uniform row height.
- Infinite loading. When
endIdxapproachesdisplayData.length, fetch the next page, append to the array, and callupdateSpacer(). Windowing and paging compose cleanly — one bounds the DOM, the other bounds the data.
The things deliberately left out
Variable row heights. This is the big one, and it's why the technique here is the simple version. The moment rows can differ in height, index * ITEM_HEIGHT stops being a valid position and scrollTop / ITEM_HEIGHT stops being a valid index. Real solutions measure rows as they're rendered, cache the measurements, keep a running offset table, and estimate the heights of rows that haven't been seen yet — which is why the scrollbar in a variable-height virtual list subtly shifts as you scroll into fresh territory. It's a genuinely harder problem, and the reason libraries exist.
Accessibility. The list is a stack of divs with no role="grid", no aria-rowcount to tell a screen reader that seventeen visible rows represent ten thousand, and no keyboard navigation. This is the standard weakness of every virtualized list — the accessibility tree only contains what's rendered — and it's the first thing to add if the component is going into a real product.
State attached to a row. Because elements are recycled, a row is not a stable identity. If you add an expandable detail panel, an inline editor or a focused input, that state has to live in the data — keyed by record id — not on the element, or it will appear to jump to a different record when you scroll. This trips people up more often than the math does.
One rough edge worth knowing about: sorting sets displayData to a sorted copy of the current view, but searching re-derives displayData from allData. So sorting and then searching discards the sort while the button still says "Z–A". The fix is to make one function own the pipeline — filter, then sort, then render — and have both inputs call it, which is the same "one function owns the derived state" principle the render loop already follows.
Using it in React, Vue, or Angular
The editor exports all four, and the shape of the port is consistent. The scroll container gets a ref; scrollTop goes into state (or better, a ref plus a throttled state update); the visible slice is derived during render as displayData.slice(startIdx, endIdx) and mapped to elements, each with style={{ top: dataIdx * ITEM_HEIGHT }} and — critically — key={item.id} and not the array index, so React reconciles rows by identity as the window moves.
The one thing to unlearn is the pool. Manual node recycling is exactly what a virtual DOM already does: render a 17-element list from a slice and React will reuse the existing elements and patch their text, which is the same optimization the while loops implement by hand. Keep allData outside component state (it never changes and putting ten thousand objects in state buys you nothing), memoize the filtered-and-sorted array with useMemo keyed on the query and sort direction, and debounce the query in a useEffect. Vue's computed properties map onto the memo directly; Angular's OnPush change detection with a derived observable does the same job. If you'd rather not maintain any of it, react-window and TanStack Virtual implement precisely what's above, plus the variable-height and accessibility work.
Build, understand, optimize, and extend it with AI
This snippet rewards being interrogated instead of skimmed, because the interesting parts are ordering decisions, not syntax. Paste its HTML, CSS and JS into an assistant like Claude and start with the window math: ask it to explain why startIdx is clamped to 0 only after endIdx is computed from the unclamped value, and to work out exactly what changes if you swap those two lines — the answer is smaller than it looks, and being able to say precisely how much smaller is the test of whether you understand the window. Then ask what happens if ITEM_HEIGHT is 64 in the JavaScript but a row's actual rendered height is 66 — have it predict the drift and then verify it in a copy, because seeing rows creep out of alignment as you scroll is the fastest way to understand why this technique depends on that constant being exactly true. For optimization, ask whether writing row.style.top should be transform: translateY() instead and what that changes about which browser pipeline stages run, and whether the pool loops could ever thrash if the container is resized continuously. For extension, in roughly increasing difficulty: unify search and sort into a single pipeline function so sorting survives a search, add role="grid" with aria-rowcount and arrow-key navigation that scrolls the focused row into view, add sticky group headers using the fact that you always know the top row's index, and finally attempt variable row heights with a measured offset cache — which will teach you more about why virtualization libraries exist than any amount of reading their source. Treat the code less like a finished artifact and more like a starting point for a conversation.
Prompt to recreate it
Copy this into your AI assistant of choice to build the component from scratch, or as a jumping-off point for your own variant:
Build a virtual scrolling list that smoothly displays 10,000 rows, in plain HTML, CSS, and vanilla JavaScript with no libraries.
Requirements:
- A fixed-height scroll container with overflow-y: auto and position: relative, containing exactly two children: an empty "spacer" div whose height is set in JavaScript to the item count times a fixed per-row pixel height, and an absolutely positioned row container overlaying it at top 0. The spacer must never hold content — its only job is to give the scrollbar a correct range and thumb size.
- A single render function that reads scrollTop and clientHeight, computes a start index as floor(scrollTop / rowHeight) minus a small buffer, an end index as start plus the number of visible rows plus twice that buffer, and only then clamps the start index to 0 and the end index to the array length — in that order, so the window is naturally shorter at the very top of the list rather than rendering phantom rows.
- Maintain a reusable pool of row elements: grow it by appending new rows while there are fewer elements than needed and shrink it by removing trailing elements while there are more, then rewrite the existing elements' text content in place. Never clear the container and rebuild all rows on a scroll.
- Position each visible row absolutely at top = its real data index times the fixed row height, so rows appear at their true position in the full list even though only a viewport's worth of elements exist. Assign all text with textContent, and replace (not append to) any class that varies per record so a recycled row cannot keep the previous record's state.
- Throttle scrolling to at most one render per animation frame using a pending flag plus requestAnimationFrame, so a burst of scroll events coalesces into a single DOM update per frame.
- Add a search input, debounced by about 150ms, that always filters from the full source array (never from the already-filtered array), resets scrollTop to 0, recalculates the spacer height for the new result count so the scrollbar shrinks to match, and re-renders using the same windowing logic.
- Add a sort toggle that reorders a copy of the current array (never mutating the source in place) with a direction multiplier of 1 or -1 applied to a localeCompare comparator, resets scroll to the top, and re-renders without touching the spacer height, since sorting does not change the item count.
- Generate at least 10,000 synthetic records with a name, role, status and a colored avatar with initials, spacing avatar hues by the golden angle (index * 137.5 modulo 360) so adjacent colors never clump.
- Display a live count of matching items so it is obvious that filtering affects the data while the number of DOM rows stays constant.
Final thought
The thing to take away isn't the code, which you could rewrite from memory after reading it twice. It's the shape of the trick: decouple what the scrollbar believes from what the DOM contains. One empty element carries the illusion of size, one division converts a pixel offset into an array index, and a pool of a dozen or so elements does all the actual work — forever, at the same cost, whether the list holds ten thousand rows or ten million.
That decoupling shows up all over performance work once you start looking for it. Sprite sheets, texture atlases, tile-based map renderers, database cursors, streaming pagination — all of them are the same bargain in a different costume: keep the model complete, keep the materialized view small, and derive the second from the first on demand. Virtual scrolling is just the version of that bargain you can read in forty lines and watch working in a browser tab.
