Build a virtualised long list — 10,000 rows, smooth scroll
By the end of this recipe you'll have a 50-line hand-rolled window-virtualiser that renders only the visible rows of a 10k-item list, supports variable row heights, and bidirectional infinite scroll.
Overview
A scroll container, a measured row height, and a render window that slides as the user scrolls. We render maybe 30 rows at a time even when the array has 10,000.
The browser can render 200 rows fine. It cannot render 10,000 — paint stalls, scroll judders, and your "fast" device becomes the slow one. Virtualisation trades flat-render simplicity for a render budget that doesn't depend on array length.
Virtualise above 200 items. Below that, regular DOM is fine and you get free Cmd-F, free print, free a11y. Don't pre-emptively virtualise a 30-row list "to be ready" — you'll fight the trade-offs forever.
Skip this recipe if your list is paginated (use the table-server-paginated recipe), if you can group + collapse (use grouped-by-section), or if every row is a different complex card (virtualisation assumes consistent enough rows to estimate).
What you'll build
A 10k-row list, mid-scroll. The DOM shows ~30. The scrollbar shows the full extent.
All transactions (10,000)
All transactions (10,000)
All transactions (10,000)
Required pieces
A scroll container, a measure ref, and the virtualiser itself.
@corelithzw/react exports used here: RowCard, Spinner. The virtualiser is hand-rolled — about 50 lines.
VirtualList primitive is planned for @corelithzw/react — for now we ship the recipe with the hand-rolled implementation so you understand the trade-offs. When VirtualList lands, it'll be a drop-in replacement.
Step-by-step build
The state machine — scrollTop, viewport, item count
Three numbers drive the whole virtualiser: how far the user has scrolled, how tall the viewport is, and how many items the data has. Everything else is derived. Don't store start/end indexes — derive them, or they drift out of sync with scrollTop.
Two numbers in (scrollTop, viewportH), four numbers out (start, end, totalHeight, offsetTop). The whole thing fits on one screen — that's the point.
The render shape — spacer above, real rows, spacer below
The trick: render the visible rows inside a translated container, and let the total scroll height come from a single absolutely-positioned spacer of totalHeight. The scrollbar reads the spacer; the user sees the rows; the browser only paints what's on screen.
Inner = totalHeight spacer (the "runway").
Slice = the rows you actually see, translated into place.
Variable heights — measure on first render, cache
For variable rows, replace the constant rowHeight with a measured map. After each row mounts, store its real height in a ref-cached Map. Re-derive the running offsets on each render. Yes, the first scroll is slightly off — that's the cost.
The offset() sum is O(n) but only over rendered rows; for 30-row windows it's negligible. For truly long lists, replace with a prefix-sum tree if you measure jank.
Infinite scroll — sentinel at both ends
An IntersectionObserver on a top sentinel and a bottom sentinel fires the load handler. Older-direction loads must prepend without scroll-jumping — restore scrollTop after the prepend by the prepended-rows height. Otherwise scrolling up teleports you.
Bottom sentinel = load newer.
Use withAnchor() when prepending so the user's view doesn't jump.
Final composition
~120 lines, including hooks. Hand-rolled, no library.
Variations
Three real ways this recipe forks.
Fixed-height — drop the measure ref
If every row is identical (transactions of the same shape), skip measurement entirely. Trade variable-height flexibility for ~10% less code and exact-perfect scroll anchoring.
// remove useMeasuredHeights // rowHeight is a literal const const ROW_H = 56; // offsetTop = start * ROW_H // totalHeight = count * ROW_H
Horizontal virtualisation
Same idea sideways — for wide tables with 100+ columns. scrollLeft instead of scrollTop, translateX instead of translateY. Don't combine horizontal and vertical virtualisation unless you have a real measured need.
const start = Math.floor(
scrollLeft / colWidth
) - overscan;
transform: `translateX(${
offsetLeft}px)`
Use the @corelithzw/react VirtualList when it ships
The roadmap <VirtualList> wraps this whole hook into a single component. Until then, the hand-rolled one in Final is the canonical implementation.
// future:
<VirtualList
items={items}
rowHeight={56}
renderRow={(t) => <RowCard … />}
onReachEnd={loadMore}
/>
Accessibility
Virtualisation hides rows from the DOM — you have to put the count back somehow.
- Use
role="feed", notrole="list". Lists imply a known total in the DOM — feeds explicitly support virtualisation. Screen readers announce "feed, 10000 items". - Set
aria-posinsetandaria-setsizeon every visible row. This restores the "row 4815 of 10000" announcement even though only 30 are in the DOM. aria-busy="true"on the container while loading. Pair with the sentinel spinner so the assistive tech knows new rows are arriving.- Don't break Cmd-F. Browser find can't find rows that aren't rendered. Document this loudly to users — provide a search input above the list as the replacement.
- Keyboard scroll works. The scroll container has
tabindex="0"so PgUp/PgDn/Home/End scroll without needing a row focused. Otherwise keyboard-only users are trapped.