ADR-0019 Virtual lists
Company standard virtual lists — TanStack Virtual, headless, for long lists, feeds, and large grids once pagination and CSS cannot do the job.
- Decision
- TanStack VirtualBuild the app
- Status
- Accepted
- Date
- Scope
- Company-wide default for all new apps
Connections
Context
Section titled “Context”Some screens show more rows than a browser should render at once: log viewers, audit trails, long pickers, chat and streamed AI output, large tables (ADR-0018). Every extra row is DOM nodes, layout work, and memory. Virtualization renders only the rows in the scroll viewport plus a small buffer.
It has costs, so it is not the first answer. The surrounding decisions:
- React and SSR: TanStack Start (ADR-0002). A server has no layout, so a virtualizer cannot know how many rows fit
- Components: shadcn/ui on Base UI with Tailwind v4 (ADR-0011); our markup, our tokens
- Data: Query and URL state (ADR-0012). Virtualization renders data already in the browser; it does not shrink what is fetched
- Tables: paginated on the server by default (ADR-0018); virtualization is the exception for very large loaded sets
- Tests: component tests run in a real browser (ADR-0014), which is what a virtualizer needs — a simulated DOM has no layout to measure
What is open: which tool, and the rules that keep virtualized lists accessible.
Forces:
- Often not needed. Pagination, server-side filtering, or plain CSS may solve the same problem with less risk
- Accessibility. Rows outside the viewport are not in the DOM: browser find-in-page and assistive technology cannot reach them, and a focused row can be unmounted by scrolling
- SSR. The first HTML should not be an empty list
- Headless. The rows are shadcn/ui table rows, list items, or cards — the library must not dictate markup
- TanStack Table fit. Large grids pair rows from
table.getRowModel()with a virtualizer (ADR-0018)
Options considered
Section titled “Options considered”1. TanStack Virtual
Section titled “1. TanStack Virtual”Headless virtualizer: a hook that returns which items to render and where. @tanstack/react-virtual 3.14.x on @tanstack/virtual-core 3.19.x.
Pros
- Headless: no markup or styles, so it works over shadcn/ui table rows,
ulitems, or cards; vertical, horizontal, and grid layouts (lanes, or one virtualizer per axis);useWindowVirtualizerfor page-level scroll - The path TanStack Table documents: its virtualization guide, with row, column, and infinite-scroll examples, is built on it (ADR-0018)
- Covers the hard parts: dynamic measurement (
measureElement),scrollToIndex,rangeExtractor(keep chosen items rendered),initialRectandinitialOffsetfor SSR, scroll restoration (takeSnapshot), and end-anchored feeds for chat and logs (anchorTo: "end",followOnAppend) - Same maintainers as Router, Query, Start, and Table; MIT; v3 stable since 2023-12-01; 12 releases between 2026-08-18 and 2026-10-16 (react-virtual 3.14.21 is the latest); the most used option (16.4 M npm downloads in the week to 2026-10-18, against 3.4 M for
react-window)
Cons
- Headless means accessibility is ours: it renders no roles, no ARIA, and no keyboard handling, and its docs do not cover any of the three
- Sizes are estimates until measured; a poor
estimateSizewith dynamic heights causes scroll jumps and a drifting scrollbar. Its docs recommend estimating the largest size, and TanStack Table’s guide says to prefer fixed row sizes - On the server it renders no rows unless given
initialRect(we checked on 3.14.21: 0 rows without it, 7 with a 200 px viewport and 35 px rows) - On React 19, scrolling can log a
flushSyncwarning; the documented fix isuseFlushSync: false, which its docs describe as trading some scroll accuracy for smoother updates - Two version numbers: the React adapter and the core are released separately, and each adapter version pins one exact core
2. react-window
Section titled “2. react-window”Components for fixed-size and variable-size lists and grids. 2.4.1, MIT.
Pros
- Small and focused:
ListandGrid, sizing withoutAutoSizer, default ARIA roles, and row components receiveariaAttributes - The best server-rendering story of the group:
defaultHeightdecides how many rows are server-rendered, withdefaultScrollOffsetandinitialRowHeightsadded in 2.4.0 - MIT; 3.4 M npm downloads in the week to 2026-10-18
Cons
- Component-shaped (
rowComponentplusrowProps), not hook-shaped; TanStack Table’s virtualization guide does not use it, so a grid means our own wiring - Dynamic row heights are “not as efficient as predetermined sizes” (its README)
- v2 (2025-08-28) was a rewrite: v1
FixedSizeListsnippets and tutorials no longer apply
3. react-virtuoso
Section titled “3. react-virtuoso”Pros: variable-size items with no manual measuring; Virtuoso, GroupedVirtuoso (sticky group headers), VirtuosoGrid, TableVirtuoso (documented to work with TanStack Table), and masonry; core is MIT (react-virtuoso 4.18.19).
Cons: its components render their own scroller and item wrappers, so control over markup goes through slots; the chat Message List component is under a commercial licence; a second way to virtualize next to the one TanStack Table documents.
4. virtua
Section titled “4. virtua”Pros: zero-config, with automatic dynamic sizing; components start from about 3 kB gzipped and are tree-shakeable; VList, VGrid, keepMounted, SSR through ssrCount; MIT.
Cons: pre-1.0 (0.53.5) — the API can still change under a company standard; smaller community (0.66 M npm downloads in the week to 2026-10-18); a real <table> element needs customization (its own comparison table says so), and TanStack Table’s docs do not cover it.
5. CSS content-visibility: auto
Section titled “5. CSS content-visibility: auto”No JavaScript: the browser skips rendering and layout of off-screen subtrees.
Pros
- Off-screen content stays in the DOM and the accessibility tree, and find-in-page, tab order, and focus keep working (MDN)
- Baseline since September 2024 (Safari 18, Firefox 125); no dependency; SSR is trivial
- web.dev’s demo went from 232 ms to 30 ms of initial rendering
Cons
- It skips rendering work, not DOM nodes: every node stays in the DOM, so it will not carry tens of thousands of rows
- Needs
contain-intrinsic-size(auto <length>) per item, or skipped items collapse and the scrollbar jumps
6. No virtualization: pagination or “load more”
Section titled “6. No virtualization: pagination or “load more””Pros: the simplest; every row is real DOM, so find-in-page, screen readers, and links work; works on the server unchanged. Cons: no continuous scrolling; poor for dense scanning (logs, feeds, spreadsheet-like grids).
Decision
Section titled “Decision”TanStack Virtual is the company standard for virtualization, and the last step of a short ladder: paginate or filter first, then CSS, then Virtual. It is the standard for virtualized tables as well (ADR-0018).
Concretely:
- The ladder. 1) If the user does not need every row on screen, paginate or filter on the server (ADR-0018, ADR-0012). 2) For long static, in-DOM content where find-in-page matters, use
content-visibility: autowithcontain-intrinsic-size: auto <px>. 3) When DOM size is the measured problem — thousands of interactive rows, streams, dense grids — use TanStack Virtual. Measure before climbing - Package:
@tanstack/react-virtualonly. It pins its@tanstack/virtual-coreexactly; never install the core separately, and don’t expect the two version numbers to match - One shared hook per app (
src/components/virtual/) wrapsuseVirtualizer, so house defaults live once. Components that render the rows stay presentational: they receive the virtual items and render. Keepoverscanat its default of 1 until profiling shows blank rows while scrolling, then raise it. If React 19 logs theflushSyncwarning, setuseFlushSync: falsein that hook - One scroll element with a fixed height (
overflow-auto;min-h-0inside flex). UseuseWindowVirtualizeronly for page-level lists, withscrollMarginfor anything above the list. Rows sit absolutely positioned in arelativecontainer sized bygetTotalSize(), as in TanStack’s examples - Stable keys, fixed heights.
getItemKeyreturns the record id, never the index, and is memoized withuseCallback. Prefer fixed row heights; adddata-indexandref={virtualizer.measureElement}only for rows that really vary. In TanStack Table’s examples, dynamic row measurement is skipped in Firefox, which measures table borders differently - SSR: pass
initialRectwith the container’s fixed height so the first rows are in the HTML, andinitialOffsetwhen restoring a position. Where the height is not known up front, render a skeleton untiluseHydrated()(ADR-0013). Never ship a virtual list that renders as an empty server HTML - Data: a virtual list over remote data loads pages with
useInfiniteQuery(ADR-0012) and fetches the next page when the last virtual index nears the loaded count. Sorting and filtering stay on the server; never mix them with client-side processing over loaded pages (ADR-0018). Chat and streamed AI output use the end-anchored mode (anchorTo: "end",followOnAppend; see ADR-0025) - With TanStack Table:
countistable.getRowModel().rows.length; rendervirtualizer.getVirtualItems(), neverrows.map(), and readrows[virtualRow.index]. Tables follow TanStack’s layout:display: gridontableandthead,display: flexontr, cells sized bycolumn.getSize(), and a stickythead. Our copy of shadcn/ui’sTablewraps the<table>in anoverflow-x-autodiv, which is itself a scroll container: for virtualized tables, give that wrapper the fixed height and use it as the scroll element (acontainerRefandcontainerClassNameon ourTable), or the sticky header is tied to the wrapper instead of our scroller - Semantics are ours:
- Tables stay real tables. Browsers have kept table semantics under
display: gridsince 2023 (display: contentsis the exception); test with a screen reader anyway. Putaria-rowcount(header included) on thetableandaria-rowindexon every rendered row, header = 1, because rows are missing from the DOM - Lists are
ul/li(orrole="list"/"listitem") witharia-setsizeandaria-posinseton each item, as ARIA requires when only part of a set is in the DOM
- Tables stay real tables. Browsers have kept table semantics under
- Focus survives scrolling. A custom
rangeExtractoradds the focused or active index todefaultRangeExtractor’s result, so that row never unmounts. Without it, scrolling away drops focus, and a rovingtabindex="0"that unmounts leaves the list with no tab stop - Keyboard: one tab stop per list. Use roving
tabindex(the browser scrolls the focused element into view) oraria-activedescendant(the active item must stay a DOM descendant, hence the previous rule). Arrow, Page Up/Down, Home, and End callscrollToIndex(), then focus the row once it mounts; callcancelScroll()when the user wheels, touches, or presses a key mid-scroll. A read-only table keeps native semantics (all focusable controls are tab stops);role="grid"and its arrow-key model only for interactive or editable grids (ARIA Authoring Practices). Row-level shortcuts go through ADR-0024 - No find-in-page. Off-screen rows can’t be found with Ctrl+F; give every virtualized list a search or filter control
- Tests: browser-mode component tests (ADR-0014) assert that the expected rows render, that
scrollToIndexmounts its target, and that focus survives a scroll; axe runs on pages with virtual lists
The deciding factors: headless, so our markup and tokens stay; the library TanStack Table documents for large grids; one release train with Router, Query, and Table; and the hard parts (dynamic sizes, SSR hooks, end-anchored feeds) already built. We accept that accessibility, focus, and keyboard handling are ours, and that virtualization is a last resort rather than a default.
Consequences
Section titled “Consequences”Easier:
- One virtualization API for lists, tables, grids, and feeds, with markup that stays shadcn/ui
- Large tables pair with Table’s documented pattern; chat and log views use a built-in end-anchored mode
- A shared hook and shared row components make the accessibility rules (counts, indexes, focus) a default, not a per-screen chore
Harder:
- Accessibility is ours:
aria-rowcount/aria-rowindexoraria-setsize/aria-posinset, focus kept mounted, and keyboard handling must be built once and reviewed. Nothing here is free from the library - No browser find-in-page, and virtualized rows are invisible to assistive technology until they scroll in; a search or filter control is the substitute
- Layout is more fragile than a plain list: a fixed-height scroller, absolute positioning, a custom grid layout for tables, and the
Tablewrapper change - Dynamic heights cost scroll accuracy; fixed heights cost design freedom
- Without
initialRect, the first HTML is empty; each virtual list needs its SSR story - Tests need a real browser and real layout;
react-virtualandvirtual-coreupgrade together, with two version numbers
Given up: react-window’s built-in ARIA roles and tidy SSR props, react-virtuoso’s and virtua’s zero-config dynamic sizing, and Virtuoso’s ready-made grouped, grid, and table components. Accepted — one headless API that TanStack Table documents matters more for a company default; a product that needs a chat-grade message list can weigh a commercial component in its own ADR.

