Virtualized Large Lists
Render only the visible rows of a very large list.
How it works
A list of up to 100,000 tickets that scrolls like a list of 20: only the rows near the visible area exist on the page, and a spacer as tall as the whole list gives the scrollbar its true size. Rows have different heights, so each one starts with an estimate and is measured once drawn, without the view ever jumping.
- A window, not a list: render the visible rows plus a few above and below (overscan); everything else is empty space.
- Estimate, then measure: unknown rows use a guessed height; drawn rows report their real one.
- Anchor the view: when heights change, put the first visible row back where it was before the frame is painted.
- Keep the active row alive: the focused row stays on the page even when scrolled away, so keyboard and screen reader users never lose their place.
Read moreShow less
Where you'd use it
- Logs, inboxes, ticket queues, chat history, transaction lists: anything with thousands of rows.
- Pickers and autocompletes over large option sets (countries, products, users).
- Long tables and spreadsheets (the same idea per column is practice 13).
- Infinite feeds that have grown long enough to slow the page down.
How we built it
The algorithm lives in a plain class with no React in it. Row sizes are stored by ticket, so they survive filtering and sorting, and a Fenwick (binary indexed) tree over them answers “where does row i start?” and “which row is at pixel y?” in O(log n). A prefix-sum array would be just as fast to read, but every measured row would cost O(n) to update.
Each row is absolutely positioned with translateY inside a spacer of the total height, so a wrong estimate moves only our numbers, never the layout. After every commit, a useLayoutEffect measures the rendered rows, recomputes the scroll position from the anchor (first visible row + pixels into it) and sets scrollTop; the store notification that follows re-renders synchronously, so the first frame the browser paints is already corrected. The browser's own scroll anchoring is turned off (overflow-anchor: none) so it doesn't correct a second time.
Jumps and arrow keys set a reveal target (“row 87,000 at the top”, “row 12 fully visible”) that is re-applied after every correction until the user scrolls: rows above the target are only estimates when the jump starts, and they settle as they are drawn. Scroll events we cause ourselves are told apart from the user's by the position we last set.
The list box uses a roving tabindex: focus sits on a real row element, and that row is pinned into the render even when it is far outside the window. Every option carries aria-posinset and aria-setsize, so “12 of 100,000” is announced although only about 20 options exist. Only the scroll box is observed for size changes (a new width re-wraps every row, so measurements are dropped and the anchor kept); rows are measured in the layout effect instead, which avoids ResizeObserver loop errors.
Tickets are generated in the browser from one seed per ticket, in chunks of 5,000 so 100,000 never block the page in one go. The filter runs through useDeferredValue over pre-folded text (accents and case removed once), and sort orders are cached.
Best practices
Do: Measure real heights and correct the scroll position against an anchor before paint.
Avoid: Assuming every row has the same height, or correcting in a later frame so the content visibly jumps.
Do: Store measured sizes by item identity, so they survive filtering, sorting and inserts.
Avoid: Caching sizes by position: after a sort every row gets someone else's height.
Do: Keep the focused row rendered and move focus with preventScroll, then scroll yourself.
Avoid: Letting the focused element unmount when it scrolls out of the window: focus falls back to the page body.
Do: Give options their position in the whole list (aria-setsize / aria-posinset).
Avoid: Letting assistive tech count only the rows that happen to be in the DOM.
Do: Use a small overscan and positioned rows (transform) inside a full-height spacer.
Avoid: Huge overscan, or padding tricks that make every row reflow when one changes.
Try it with the demo controls
- Rows
- At 100,000 the scrollbar is tiny but “Rows on page” stays around 20–30; the same row number always shows the same ticket.
- Rendering: Naive
- At 10,000 rows “Rows on page” jumps to 10,000 and “Last update” to hundreds of ms; switch back and both drop while the top row stays put (that switch also pays for removing the 10,000 rows). Disabled above 20,000.
- Overscan
- 0 can flash blank space on fast flings; 20 renders more rows per frame. Watch “Rows on page”.
- Estimated height
- Set it to 40 or 200 and drag the scrollbar: the thumb resizes as rows are measured, but the rows you're looking at never move.
- Debug view
- Each rendered row shows its number and whether its height is measured or estimated; overscan rows are tinted. The label makes every row taller, yet turning it on doesn't move the row at the top. “Measured” counts rows drawn so far.
- Go to row
- Type 87,000: the row lands exactly at the top, even though the rows above it were never measured.
- Filter / Sort
- Select a ticket, then filter or sort: it stays at the same place in the box if it's still in the list. Accents don't matter (“jose” finds “José”).
- Keyboard
- Tab into the list, hold Down, press End, Page Up: focus always stays on a visible row; Enter selects.
Trade-offs and limits
- Browsers cap element heights (about 17 million px in Firefox, 33 million in Chrome); beyond ~150,000 rows of this size the spacer would need scaling, which we don't do.
- Find-in-page (Ctrl+F) and in-page anchors can't see rows that aren't rendered; the filter is the replacement.
- Setting
scrollTopduring a touch fling can cut the momentum short on some mobile browsers; corrections are rare once rows near the view are measured. - We scroll an inner box. Virtualizing against the page scroll works the same way, offset by where the list starts on the page, but has to share the scroll with everything else.
- Rows must not change height on their own while hidden; anything that does (images, expanding content) needs to be measured again when it changes.
Demo controls
0 of 0 tickets
- Rows on page
- 0
- In list
- 0
- Visible
- –
- Last update
- –
Generating 10,000 tickets… 0%
Selected ticket
Click a ticket, or focus the list and press Enter, to see its details here.