@tanstack/react-virtual
@tanstack/react-virtual is the React binding for TanStack Virtual, a headless list virtualizer. You give the useVirtualizer hook a count, a function that returns your scroll container, and an estimated item size. It hands back a list of visible items with index, key, start offset, and size, plus a total size for the spacer. It renders nothing itself: no components, no CSS, no opinions about your markup. You keep writing your own divs and styles, and the hook just tells you which slice of 100,000 rows is currently on screen.
The best headless virtualizer in React right now: small, actively developed, and it never touches your markup. Budget half a day for the CSS contract and measurement quirks, and reach for react-virtuoso instead if you want the list handed to you finished.
Use it if
- You are rendering a list, table, or grid with thousands of rows and the browser is choking on DOM nodes rather than on your data fetching
- Your rows have unpredictable heights (chat messages, comment threads, wrapped text) and you need real measurement instead of a hard-coded row height
- You already own the markup: a design-system table, a CSS grid, a component with custom hover and focus behavior that a prebuilt list component would fight you on
- You need window scrolling rather than an inner scroll container, which useWindowVirtualizer handles with a scrollMargin offset
- You are pairing it with TanStack Table or an infinite query and want one virtualizer instance you can drive from your own state
- Your list is under a few hundred rows: virtualization adds absolute positioning, measurement, and scroll-restoration bugs to buy performance you do not need yet
- You want a component, not a hook: react-virtuoso ships a working list with grouping, sticky headers, and reverse scrolling in about ten lines, where this makes you build all of that from rangeExtractor and CSS
- Ctrl-F, browser print, and text selection across the whole list matter to your users, because virtualization removes offscreen rows from the DOM and no library can give that back
- You need accessible semantic tables or ARIA grids without effort: absolute-positioned rows inside a spacer div break native table layout, and getting roles, row indexes, and keyboard navigation right is on you
- Server-rendered content is your priority: the first paint only contains the estimated viewport, so search crawlers and no-JS visitors see a fraction of the list unless you render a separate static fallback
Setup reality
npm install @tanstack/react-virtual pulls in @tanstack/virtual-core and declares React 16.8 through 19 as a peer, so it installs clean on most trees. The friction is not the install, it is the CSS contract you have to honor by hand. Your scroll container needs an explicit height and overflow: auto or nothing renders, because getScrollElement measures zero. Your inner spacer needs position: relative and a height equal to getTotalSize(). Every item needs position: absolute plus a transform or a top offset. If you use dynamic measurement, the same element that gets ref={virtualizer.measureElement} must also carry data-index, and you must not set its height in style or you have pinned it to your estimate forever. React 19 users often see a flushSync warning during scroll until they pass useFlushSync: false. Add overflow-anchor: none to the scroll container or Chrome's scroll anchoring fights the virtualizer on variable heights.
Patterns
Virtualize a list of fixed-height rowsfixed-size-list
import { useRef } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
function Rows({ items }: { items: Array<string> }) {
const parentRef = useRef<HTMLDivElement>(null)
const rowVirtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 35,
overscan: 5,
})
return (
<div ref={parentRef} style={{ height: 400, overflow: 'auto' }}>
<div
style={{
height: rowVirtualizer.getTotalSize(),
width: '100%',
position: 'relative',
}}
>
{rowVirtualizer.getVirtualItems().map((row) => (
<div
key={row.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: row.size,
transform: `translateY(${row.start}px)`,
}}
>
{items[row.index]}
</div>
))}
</div>
</div>
)
}The three CSS requirements are non-negotiable: a fixed height plus overflow on the scroll parent, position: relative plus getTotalSize() on the spacer, position: absolute on every item. Miss any one and you get a blank list or one 100,000-row-tall page.
Measure rows whose height you cannot predictdynamic-row-heights
const virtualizer = useVirtualizer({
count: messages.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 60,
})
{virtualizer.getVirtualItems().map((item) => (
<div
key={item.key}
data-index={item.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${item.start}px)`,
}}
>
{messages[item.index].body}
</div>
))}data-index must sit on the exact element holding the measureElement ref, and that element must not declare a height. Estimate on the large side: too-small estimates make the scrollbar shrink visibly as the user scrolls.
Virtualize against the page scroll instead of a containerwindow-scrolling
import { useWindowVirtualizer } from '@tanstack/react-virtual'
const listRef = useRef<HTMLDivElement>(null)
const virtualizer = useWindowVirtualizer({
count: posts.length,
estimateSize: () => 240,
overscan: 3,
scrollMargin: listRef.current?.offsetTop ?? 0,
})
<div ref={listRef} style={{ position: 'relative', height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((item) => (
<div
key={item.key}
data-index={item.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${item.start - virtualizer.options.scrollMargin}px)`,
}}
>
{posts[item.index].title}
</div>
))}
</div>scrollMargin is the distance from the top of the page to the top of your list, and you must subtract it again in the transform. Forget the subtraction and every item sits one header-height too low.
Virtualize a horizontal striphorizontal-list
const colVirtualizer = useVirtualizer({
horizontal: true,
count: columns.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 120,
overscan: 3,
})
<div ref={parentRef} style={{ width: 600, height: 200, overflowX: 'auto' }}>
<div style={{ width: colVirtualizer.getTotalSize(), height: '100%', position: 'relative' }}>
{colVirtualizer.getVirtualItems().map((col) => (
<div
key={col.key}
style={{
position: 'absolute',
top: 0,
left: 0,
height: '100%',
width: col.size,
transform: `translateX(${col.start}px)`,
}}
>
{columns[col.index]}
</div>
))}
</div>
</div>With horizontal: true, estimateSize returns widths and getTotalSize() is a width. For right-to-left locales set isRtl: true or the offsets run the wrong way.
Jump to a row programmaticallyscroll-to-index
virtualizer.scrollToIndex(500, { align: 'center' })
virtualizer.scrollToIndex(0, { behavior: 'smooth' })
virtualizer.scrollToOffset(1200, { align: 'start' })
virtualizer.scrollToEnd({ behavior: 'smooth' })
virtualizer.scrollBy(-300)With dynamic measurement, scrolling far into unmeasured territory lands on estimates and then corrects, so the jump can visibly settle. Calling scrollToIndex inside the same render that sets count often runs before the scroll element exists; do it in an effect.
Give items stable keys so measurements survive reorderingstable-item-keys
import { useCallback } from 'react'
const getItemKey = useCallback((index: number) => rows[index].id, [rows])
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 48,
getItemKey,
})The default key is the array index, so prepending or sorting reassigns every measured height to the wrong row. Memoize getItemKey; an inline arrow re-creates it each render and forces recalculation.
Load the next page when the last row comes into viewinfinite-scroll
const items = virtualizer.getVirtualItems()
useEffect(() => {
const last = items[items.length - 1]
if (!last) return
if (last.index >= allRows.length - 1 && hasNextPage && !isFetchingNextPage) {
fetchNextPage()
}
}, [items, allRows.length, hasNextPage, isFetchingNextPage, fetchNextPage])Depend on the items array, not on virtualizer, or the effect never re-runs. Render a placeholder row for index >= allRows.length so the count can include the incoming page without reading undefined.
Virtualize rows and columns togethergrid-virtualization
const rowV = useVirtualizer({
count: 10000,
getScrollElement: () => parentRef.current,
estimateSize: () => 32,
overscan: 5,
})
const colV = useVirtualizer({
horizontal: true,
count: 200,
getScrollElement: () => parentRef.current,
estimateSize: () => 100,
overscan: 3,
})
<div style={{ height: rowV.getTotalSize(), width: colV.getTotalSize(), position: 'relative' }}>
{rowV.getVirtualItems().map((row) =>
colV.getVirtualItems().map((col) => (
<div
key={`${row.key}:${col.key}`}
style={{
position: 'absolute',
top: 0,
left: 0,
height: row.size,
width: col.size,
transform: `translate(${col.start}px, ${row.start}px)`,
}}
>
{cell(row.index, col.index)}
</div>
)),
)}
</div>Two virtualizers share one scroll element, which is supported. Dynamic measurement across both axes is not: measureElement reads a single dimension, so grids want fixed cell sizes.
Keep a section header pinned with rangeExtractorsticky-headers
import { useCallback, useRef } from 'react'
import { defaultRangeExtractor, useVirtualizer } from '@tanstack/react-virtual'
import type { Range } from '@tanstack/react-virtual'
const stickyIndexes = [0, 25, 60]
const activeSticky = useRef(0)
const rangeExtractor = useCallback((range: Range) => {
activeSticky.current =
[...stickyIndexes].reverse().find((i) => range.startIndex >= i) ?? 0
const next = new Set([activeSticky.current, ...defaultRangeExtractor(range)])
return [...next].sort((a, b) => a - b)
}, [])
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 36,
rangeExtractor,
})rangeExtractor only forces the header to stay rendered; you still write the CSS that pins it (position: sticky with top: 0 and a z-index above the absolutely positioned rows).
Anchor a chat or log view to the endchat-pinned-to-bottom
const virtualizer = useVirtualizer({
count: messages.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 72,
getItemKey: (index) => messages[index].id,
anchorTo: 'end',
followOnAppend: 'smooth',
scrollEndThreshold: 24,
})
const showJumpButton = !virtualizer.isAtEnd()anchorTo: 'end' keeps the viewport stable when older messages are prepended, and followOnAppend only auto-scrolls if the user was already at the bottom. Both need a stable getItemKey: index keys cannot tell a prepend from an append.
Restore measurements and offset after navigationrestore-scroll-position
// leaving the route
sessionStorage.setItem(
'inbox',
JSON.stringify({
snapshot: virtualizer.takeSnapshot(),
offset: virtualizer.scrollOffset,
}),
)
// coming back
const saved = JSON.parse(sessionStorage.getItem('inbox') ?? 'null')
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 48,
initialMeasurementsCache: saved?.snapshot,
initialOffset: saved?.offset,
})The cache is read once on the first measurement pass after mount, so it has to be present in the initial options object. Only rows the user actually scrolled past are in the snapshot; everything else falls back to estimateSize.
Split the list into columns for a masonry layoutmasonry-lanes
const virtualizer = useVirtualizer({
count: photos.length,
getScrollElement: () => parentRef.current,
estimateSize: (i) => photos[i].estimatedHeight,
lanes: 3,
laneAssignmentMode: 'measured',
gap: 12,
})
{virtualizer.getVirtualItems().map((item) => (
<div
key={item.key}
data-index={item.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: `${item.lane * 33.33}%`,
width: '33.33%',
transform: `translateY(${item.start}px)`,
}}
/>
))}Each item gets a lane index and items go to the shortest lane. The default 'estimate' mode locks lanes from estimateSize before measuring, so bad estimates give lopsided columns; 'measured' fixes that but items can shift once on first measure.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| react-virtuoso | npm | You want a finished list component with grouping, sticky headers, and reverse scrolling instead of building the markup yourself. |
| react-window | npm | Fixed-height rows, a small dependency, and a simple render-prop API are all you need. |
| virtua | npm | You want automatic item measurement with no manual refs, spacers, or absolute positioning. |
| @tanstack/virtual-core | npm | You are outside React (vanilla JS, or writing your own framework adapter) and want the same engine. |