Home/ Cookbook/ Lists/ Virtualised long list

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.

Lists 1 primitive · 0 blocks · 0 patterns ~25 min React · @corelithzw/react

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.

Huchu

All transactions (10,000)

TXN-00001$12.40
TXN-00002$8.90
TXN-00003$220.00
TXN-00004$45.00
TXN-00005$12.40
TXN-00006$8.90
[scroll · 4 of 10,000 visible]
01 Top of list — first window rendered
Huchu

All transactions (10,000)

TXN-04812$45.00
TXN-04813$12.40
TXN-04814$8.90
TXN-04815$220.00
TXN-04816$45.00
TXN-04817$12.40
[middle · row 4,815]
02 Mid-scroll — window slid down
Huchu

All transactions (10,000)

TXN-09994$8.90
TXN-09995$220.00
TXN-09996$45.00
TXN-09997$12.40
TXN-09998$8.90
TXN-09999$220.00
TXN-10000$45.00
[end · loading older…]
03 End — infinite scroll trigger

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.

Roadmap note: a packaged 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

01

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.

Code-only step

Two numbers in (scrollTop, viewportH), four numbers out (start, end, totalHeight, offsetTop). The whole thing fits on one screen — that's the point.

Step 1 · useVirtualWindow@corelithzw/react
02

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.

Outer = scroll container.
Inner = totalHeight spacer (the "runway").
Slice = the rows you actually see, translated into place.
Step 2 · VirtualList
03

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.

Code-only step

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.

Step 3 · Variable heights
04

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.

Top sentinel = load older.
Bottom sentinel = load newer.
Use withAnchor() when prepending so the user's view doesn't jump.
Step 4 · Sentinels

Final composition

~120 lines, including hooks. Hand-rolled, no library.

↑ Load older
TXN-04812$45.00
TXN-04813$12.40
TXN-04814$8.90
DOM has ~30 rows · data has 10,000
VirtualTxnList.tsx@corelithzw/react

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", not role="list". Lists imply a known total in the DOM — feeds explicitly support virtualisation. Screen readers announce "feed, 10000 items".
  • Set aria-posinset and aria-setsize on 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.