The Senior Frontend Engineering Blueprint, Part 4: Virtualising 50,000 Rows Without Losing Accessibility
Rendering 50,000 DOM rows is a real performance problem, and virtualising them away is a real accessibility problem if you stop at the transform. Here is a generic VirtualList built from scratch, with the ARIA contract most implementations skip.
Render 50,000 <div>s and the tab does not crash — it just gets sluggish, then unresponsive, then the tab's fan spins up and someone files a bug titled "logs page is broken." The browser is not struggling to draw 50,000 rows; most of them are off-screen and never painted. It is struggling to hold 50,000 live DOM nodes, each with its own layout box, style resolution and event listeners, for a viewport that can physically show about twenty of them at once.
The fix — virtualisation, or windowing — is well known: render only what's visible, plus a small buffer, and fake the rest with an empty spacer. What's less well known is that most virtualisation write-ups stop the moment the numbers look good in the profiler, and ship a component that is quietly broken for keyboard and screen-reader users. This is part 4 of an eight-part series building one real Next.js platform end to end; this instalment is a single, self-contained component, so you can drop it into any React 19 app.
The virtualisation model
The idea is a straight swap of DOM footprint for arithmetic. Without virtualisation, the DOM node count is O(N) — one node per item, however many items there are. With virtualisation, it's O(K), where K is the number of rows that actually fit in the viewport plus a small overscan buffer. A 50,000-row list and a 50-row list cost the same to render; only the arithmetic to figure out which 50 changes.
+---------------------------------------------------+ <- Scrollable container (overflow-y: auto)
| | Height: containerHeight
| Phantom spacer (height = items.length * itemHeight, keeps the scrollbar honest)
| |
| +-------------------------------------------+ | <- Start of overscan
| | Buffer items (rendered but off-screen) | |
| +-------------------------------------------+ | <- startIndex
| | Visible viewport window | |
| | Rendered items, offset via translateY | |
| +-------------------------------------------+ | <- endIndex
| | Buffer items (rendered but off-screen) | |
| +-------------------------------------------+ | <- End of overscan
| |
+---------------------------------------------------+The "phantom spacer" is the trick that keeps the browser's scrollbar honest: it's a full-height empty element that gives the scroll container the correct total scroll range, while the actual rendered rows sit inside it, absolutely positioned and translated into place.
Windowing calculations
Everything comes down to three numbers, recomputed on every scroll event: which row is first, which row is last, and how far to shift the rendered block down so it lines up with where the browser thinks those rows are.
startIndex = max(0, floor(scrollTop / itemHeight) - overscan)
endIndex = min(N - 1, floor((scrollTop + containerHeight) / itemHeight) + overscan)
offsetY = startIndex * itemHeightoverscan is a small padding — a handful of extra rows rendered just outside the viewport on either side, so that a fast scroll or a PageDown keypress doesn't produce a visible flash of empty space before the next paint catches up.
Step 1: type definitions
Fixed row height keeps the maths in the previous section exact — no scroll-thumb correction needed, no ResizeObserver. That's a deliberate scope cut for this component; variable-height rows are the first item in the edge-cases table at the end.
import { ReactNode } from "react";
export interface VirtualListProps<T> {
items: readonly T[];
height: number;
itemHeight: number;
overscan?: number;
keyExtractor: (item: T, index: number) => string | number;
renderItem: (item: T, index: number, isSelected: boolean) => ReactNode;
onSelect?: (item: T, index: number) => void;
ariaLabel: string;
className?: string;
}
export interface VirtualRow<T> {
item: T;
index: number;
key: string | number;
}Step 2: the component
Three things make this more than a toy slice() call:
- Frame-throttled scrolling. The scroll handler doesn't call
setStatedirectly — it cancels any pendingrequestAnimationFrameand schedules a new one, so state updates are coalesced to the display's refresh rate instead of firing dozens of times per scroll gesture. - A single
translateYcontainer. One transform on one wrapper positions the whole rendered block; nothing recalculates per-row offsets. - A WAI-ARIA listbox contract, which is the part most implementations skip. More on that below the code.
import React, {
useState,
useRef,
useCallback,
useMemo,
useEffect,
KeyboardEvent,
UIEvent,
} from "react";
import { VirtualListProps, VirtualRow } from "./types";
export function VirtualList<T>({
items,
height,
itemHeight,
overscan = 3,
keyExtractor,
renderItem,
onSelect,
ariaLabel,
className = "",
}: VirtualListProps<T>): React.JSX.Element {
const containerRef = useRef<HTMLDivElement>(null);
const [scrollTop, setScrollTop] = useState<number>(0);
const [selectedIndex, setSelectedIndex] = useState<number>(-1);
const rafIdRef = useRef<number | null>(null);
const totalCount = items.length;
const totalHeight = totalCount * itemHeight;
// 1. Slice boundaries, recomputed only when their inputs change
const { startIndex, endIndex, offsetY } = useMemo(() => {
const rawStartIndex = Math.floor(scrollTop / itemHeight);
const visibleItemCount = Math.ceil(height / itemHeight);
const calculatedStart = Math.max(0, rawStartIndex - overscan);
const calculatedEnd = Math.min(
totalCount - 1,
rawStartIndex + visibleItemCount + overscan
);
return {
startIndex: calculatedStart,
endIndex: calculatedEnd,
offsetY: calculatedStart * itemHeight,
};
}, [scrollTop, itemHeight, height, overscan, totalCount]);
// 2. The visible subset
const visibleRows = useMemo<VirtualRow<T>[]>(() => {
if (totalCount === 0) return [];
const rows: VirtualRow<T>[] = [];
for (let i = startIndex; i <= endIndex; i++) {
const item = items[i];
if (item !== undefined) {
rows.push({ item, index: i, key: keyExtractor(item, i) });
}
}
return rows;
}, [items, startIndex, endIndex, keyExtractor, totalCount]);
// 3. Scroll handling, coalesced to one state update per animation frame
const handleScroll = useCallback((e: UIEvent<HTMLDivElement>) => {
const currentScrollTop = e.currentTarget.scrollTop;
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current);
}
rafIdRef.current = requestAnimationFrame(() => {
setScrollTop(currentScrollTop);
});
}, []);
useEffect(() => {
return () => {
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current);
}
};
}, []);
// 4. Scroll the target index into view when keyboard focus moves past the window
const scrollToIndex = useCallback(
(index: number) => {
const container = containerRef.current;
if (!container) return;
const itemTop = index * itemHeight;
const itemBottom = itemTop + itemHeight;
const currentScrollTop = container.scrollTop;
const currentScrollBottom = currentScrollTop + height;
if (itemTop < currentScrollTop) {
container.scrollTop = itemTop;
} else if (itemBottom > currentScrollBottom) {
container.scrollTop = itemBottom - height;
}
},
[height, itemHeight]
);
// 5. Keyboard navigation, following the WAI-ARIA listbox pattern
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLDivElement>) => {
if (totalCount === 0) return;
let nextIndex = selectedIndex;
const pageSize = Math.max(1, Math.floor(height / itemHeight));
switch (e.key) {
case "ArrowDown":
e.preventDefault();
nextIndex = Math.min(totalCount - 1, selectedIndex + 1);
break;
case "ArrowUp":
e.preventDefault();
nextIndex = Math.max(0, selectedIndex - 1);
break;
case "PageDown":
e.preventDefault();
nextIndex = Math.min(totalCount - 1, selectedIndex + pageSize);
break;
case "PageUp":
e.preventDefault();
nextIndex = Math.max(0, selectedIndex - pageSize);
break;
case "Home":
e.preventDefault();
nextIndex = 0;
break;
case "End":
e.preventDefault();
nextIndex = totalCount - 1;
break;
case "Enter":
case " ":
e.preventDefault();
if (selectedIndex >= 0 && selectedIndex < totalCount) {
const selectedItem = items[selectedIndex];
if (selectedItem !== undefined) {
onSelect?.(selectedItem, selectedIndex);
}
}
return;
default:
return;
}
if (nextIndex !== selectedIndex) {
setSelectedIndex(nextIndex);
scrollToIndex(nextIndex);
const nextItem = items[nextIndex];
if (nextItem !== undefined) {
onSelect?.(nextItem, nextIndex);
}
}
},
[selectedIndex, totalCount, height, itemHeight, scrollToIndex, items, onSelect]
);
return (
<div
ref={containerRef}
role="listbox"
tabIndex={0}
aria-label={ariaLabel}
aria-activedescendant={
selectedIndex >= 0 ? `virtual-item-${selectedIndex}` : undefined
}
onScroll={handleScroll}
onKeyDown={handleKeyDown}
style={{
height,
overflowY: "auto",
position: "relative",
outline: "none",
}}
className={`virtual-list-container ${className}`}
>
{/* Phantom spacer establishes the full un-virtualised scroll height */}
<div
style={{
height: totalHeight,
width: "100%",
position: "relative",
pointerEvents: "none",
}}
>
{/* One transform offsets the whole rendered block into the viewport */}
<div
style={{
transform: `translateY(${offsetY}px)`,
position: "absolute",
top: 0,
left: 0,
right: 0,
pointerEvents: "auto",
}}
>
{visibleRows.map(({ item, index, key }) => {
const isSelected = index === selectedIndex;
return (
<div
id={`virtual-item-${index}`}
key={key}
role="option"
aria-selected={isSelected}
aria-posinset={index + 1}
aria-setsize={totalCount}
style={{ height: itemHeight, boxSizing: "border-box" }}
onClick={() => {
setSelectedIndex(index);
onSelect?.(item, index);
}}
>
{renderItem(item, index, isSelected)}
</div>
);
})}
</div>
</div>
</div>
);
}That's the part worth being honest about: virtualisation is normally sold purely as a performance trick, but the accessibility cost is the part most implementations get wrong, and it isn't optional polish. Unmounting 49,980 of 50,000 rows means a screen reader has no way to know there are 50,000 rows unless you tell it explicitly, on every rendered row, every time. That's what aria-posinset={index + 1} and aria-setsize={totalCount} are doing above — without them, a screen reader user scrolling a virtualised list hears "item 1 of 12" forever, because 12 is however many rows happen to be mounted at once.
The second half of the contract is aria-activedescendant on the scroll container rather than moving real DOM focus onto each row. Real .focus() calls on a row that virtualisation later unmounts get silently redirected by the browser to document.body — which, to a screen reader or a sighted keyboard user, looks exactly like focus vanishing. aria-activedescendant tells assistive tech which row is "focused" by ID without ever moving the DOM focus away from the always-mounted container, so the unmount/remount cycle underneath it is invisible.
Step 3: 50,000 rows, for real
"use client";
import React, { useState } from "react";
import { VirtualList } from "./VirtualList/VirtualList";
interface AuditLog {
id: string;
action: string;
actor: string;
timestamp: string;
status: "SUCCESS" | "FAILED" | "PENDING";
}
const MOCK_LOGS: AuditLog[] = Array.from({ length: 50000 }, (_, i) => ({
id: `log-${i + 1}`,
action: `POST /v1/transactions/authorize/${1000 + (i % 500)}`,
actor: `service-worker-${(i % 12) + 1}@internal.cluster`,
timestamp: new Date(Date.now() - i * 60000).toISOString(),
status: i % 15 === 0 ? "FAILED" : i % 7 === 0 ? "PENDING" : "SUCCESS",
}));
export function AuditLogViewer(): React.JSX.Element {
const [selectedLog, setSelectedLog] = useState<AuditLog | null>(null);
return (
<div className="mx-auto max-w-4xl p-6">
<header className="mb-4">
<h1 className="text-xl font-bold text-white">Security Audit Stream</h1>
<p className="text-sm text-neutral-400">
50,000 virtualised rows. Use Up/Down/PageUp/PageDown/Home/End.
</p>
</header>
<VirtualList<AuditLog>
items={MOCK_LOGS}
height={500}
itemHeight={56}
overscan={4}
ariaLabel="Audit logs table"
keyExtractor={(item) => item.id}
onSelect={(item) => setSelectedLog(item)}
className="rounded-lg border border-neutral-800 bg-neutral-950"
renderItem={(item, index, isSelected) => (
<div
className={`flex h-full items-center justify-between border-b border-neutral-900 px-4 transition-colors ${
isSelected ? "bg-neutral-800" : "hover:bg-neutral-900/60"
}`}
>
<div className="flex items-center gap-3">
<span className="font-mono text-xs text-neutral-500">#{index + 1}</span>
<span className="font-mono text-xs text-neutral-200">{item.action}</span>
</div>
<div className="flex items-center gap-4">
<span className="text-xs text-neutral-400">{item.actor}</span>
<span
className={`rounded px-2 py-0.5 text-[10px] font-semibold ${
item.status === "SUCCESS"
? "bg-emerald-950 text-emerald-400 border border-emerald-800"
: item.status === "PENDING"
? "bg-amber-950 text-amber-400 border border-amber-800"
: "bg-red-950 text-red-400 border border-red-800"
}`}
>
{item.status}
</span>
</div>
</div>
)}
/>
{selectedLog && (
<div className="mt-4 rounded border border-neutral-800 bg-neutral-900 p-3 text-xs text-neutral-300">
<strong>Selected record:</strong> {selectedLog.id} | Timestamp: {selectedLog.timestamp}
</div>
)}
</div>
);
}At any moment this renders roughly 16 DOM rows — nine visible plus overscan on each side — regardless of whether MOCK_LOGS holds 50 items or 5 million.
Edge cases that don't show up in a demo
| Challenge | Root cause | Fix |
|---|---|---|
| Layout thrashing during fast scroll | Reading scrollTop immediately after a style write forces a synchronous layout recalculation |
Decouple the scroll read from the render via requestAnimationFrame, as above — never read layout-triggering properties inside the same tick you wrote styles |
| Screen reader context loss | Unmounting rows blindly makes assistive tech report "item 1 of 12" instead of "item 500 of 50,000" | aria-posinset/aria-setsize on every rendered row, always the true index and total, not the mounted count |
| Sub-pixel jitter on Safari / high-DPI screens | Non-integer scale factors turn fractional translateY values into blurred text or hairline gaps |
Keep itemHeight an integer, or switch to translate3d(0, ${offsetY}px, 0) with will-change: transform to force GPU compositing |
| Scrollbar drift with variable row heights | A fixed itemHeight * items.length spacer estimate drifts as real, varying heights get measured |
Maintain an index-to-offset prefix-sum table (a Fenwick tree works well) updated via ResizeObserver, giving O(log N) position lookups instead of a flat estimate |
| Focus loss on scroll | A row with real DOM focus gets unmounted as it scrolls past the overscan boundary, and the browser silently resets focus to document.body |
aria-activedescendant on the always-mounted root, never .focus() on a row that virtualisation can unmount |
The compressed version: virtualisation trades an O(N) DOM footprint for O(K) plus some arithmetic on every scroll event — that part is easy and well documented. The part that's easy to skip is that unmounting rows also unmounts their accessibility context, and aria-posinset, aria-setsize and aria-activedescendant are how you give it back. If your virtualised list doesn't set those three attributes, it isn't finished, whatever the profiler says.