Skip to content
Data Display

TanStack Virtual — Component Library Guide

Headless UI for virtualizing large element lists - rows, columns, and grids. Render only visible items for smooth 60fps scrolling in massive datasets.

TypeScript MIT
1.1M weekly downloads
5.2k GitHub stars
~4kB bundle size
v3.13.4 latest version
virtualizationperformanceinfinite-scrollwindowingheadless

TL;DR: TanStack Virtual renders only visible items in a large list. Pass item count and size to useVirtualizer(), map over virtualizer.getVirtualItems(), and apply the offset as a CSS transform. Keeps the DOM small whether you have 1,000 or 100,000 items. Official docs: tanstack.com/virtual.

What is TanStack Virtual?

TanStack Virtual is a headless virtualisation library - it calculates which items in a large list are visible in the scroll window and gives you their positions. You render only those items instead of all 10,000+. This keeps the DOM small and scrolling smooth regardless of dataset size.

Unlike react-virtualized or react-window, it renders no HTML itself. You control the markup.

When to use it

Virtualise lists when they have more than ~200 - 500 items. Below that, the DOM overhead is manageable. Above it, you'll see:

  • Slow initial render (browser layout calculation for thousands of elements)
  • Janky scrolling (style recalculation on every frame)
  • High memory usage

Use TanStack Virtual when building:

  • Long data tables (pair with TanStack Table's virtualisation guide)
  • Chat message history
  • Log viewers
  • Infinite scroll feeds

Key Features

  • useVirtualizer - core hook for vertical, horizontal, and grid virtualisation
  • Variable sizes - items can have different heights; measured dynamically
  • Smooth scrolling - scrollToIndex and scrollToOffset with behaviour options
  • Reverse mode - bottom-anchored lists for chat UIs
  • Overscan - render extra items above/below viewport to reduce blank flashes

Installation

npm install @tanstack/react-virtual

Basic Usage - Long List

import { useVirtualizer } from '@tanstack/react-virtual'
import { useRef } from 'react'

function VirtualList({ items }: { items: string[] }) {
  const parentRef = useRef<HTMLDivElement>(null)

  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 48,  // estimated item height in px
    overscan: 5,
  })

  return (
    // scrollable container must have fixed height and overflow-auto
    <div ref={parentRef} style={{ height: '600px', overflowY: 'auto' }}>
      {/* total height  -  creates scroll space for all items */}
      <div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
        {virtualizer.getVirtualItems().map(virtualItem => (
          <div
            key={virtualItem.key}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              height: `${virtualItem.size}px`,
              transform: `translateY(${virtualItem.start}px)`,
            }}
          >
            {items[virtualItem.index]}
          </div>
        ))}
      </div>
    </div>
  )
}

Variable Heights

const virtualizer = useVirtualizer({
  count: messages.length,
  getScrollElement: () => parentRef.current,
  estimateSize: () => 60,
  // measureElement lets the virtualizer measure actual rendered heights
  measureElement: (el) => el.getBoundingClientRect().height,
})

// In the rendered item, attach the ref:
{virtualizer.getVirtualItems().map(item => (
  <div
    key={item.key}
    ref={virtualizer.measureElement}
    data-index={item.index}
    style={{ position: 'absolute', top: 0, transform: `translateY(${item.start}px)` }}
  >
    <Message message={messages[item.index]} />
  </div>
))}

TypeScript Tips

The useVirtualizer return is fully typed. virtualItem.index is a number - use it to index your data array:

interface LogEntry { id: string; level: 'info' | 'warn' | 'error'; message: string }

const vItems = virtualizer.getVirtualItems()  // VirtualItem[]
vItems.map(vi => {
  const log: LogEntry = logs[vi.index]  // typed access
  return <LogRow key={vi.key} log={log} />
})

Common Gotchas

  • Container must have explicit height - the scroll container needs height and overflow: auto/scroll; without it the virtualizer calculates zero visible items
  • key must be virtualItem.key - not virtualItem.index. The key is stable even when items are reordered
  • Smooth scroll to index - call virtualizer.scrollToIndex(index, { behavior: 'smooth' }) after data loads for chat-scroll-to-bottom behaviour

How windowing actually works under the hood

The idea behind virtualization is older than React. You have a scroll container with a fixed height, say 600 pixels. Even if the dataset has 100,000 rows, only about a dozen fit on screen at once. So why render 100,000 DOM nodes? You render the visible dozen plus a small buffer, and you fake the scrollbar by setting the inner container to the full height all the rows would occupy.

TanStack Virtual does the math. It reads the scroll position, figures out which indexes fall inside the viewport, and hands you those VirtualItem objects with their start offsets. You absolutely position each rendered item using that offset. As the user scrolls, the set of visible indexes shifts, old items unmount, new ones mount, and the DOM node count stays flat. That's the whole game, and it's why memory usage doesn't climb with dataset size.

Tuning overscan without overdoing it

Overscan is the number of extra items rendered above and below the visible window. Set it too low and fast scrolling shows blank gaps before new rows paint. Set it too high and you throw away the performance you came for. A value between 3 and 10 covers most cases. I usually start at 5 and only bump it if I see flashing during a flick scroll on a slower device.

There's a real tradeoff here that people miss. Heavier row components (images, charts, nested lists) make each extra overscanned item more expensive. For those, keep overscan small. For simple text rows, a larger overscan is cheap insurance against blank flashes.

Grids, horizontal lists, and reverse scrolling

useVirtualizer isn't limited to vertical lists. Pass horizontal: true for a horizontal track, which is great for carousels or timelines with thousands of frames. For grids, you run two virtualizers - one for rows, one for columns - and combine their virtual items to render only the visible cells. A spreadsheet with a million cells stays smooth because you might only ever render 200 of them.

Chat interfaces want the opposite of normal scrolling: new messages appear at the bottom and the view stays pinned there. You handle this by calling scrollToIndex on the last item after each new message and accounting for the user scrolling up to read history. Don't yank them back to the bottom while they're reading - check the scroll position first.

Measuring dynamic content correctly

Fixed-height rows are simple. Variable heights are where bugs live. The measureElement approach measures each row after it renders and corrects the layout. The non-obvious requirement is the data-index attribute on every measured element. The virtualizer uses it to map a DOM node back to its data index. Forget it and your offsets drift, items overlap, and the scrollbar jumps. Attach ref={virtualizer.measureElement} and data-index={item.index} together, every time.

  • TanStack Table - combine with TanStack Virtual for virtualised data grids handling thousands of rows
  • TanStack Query - use TanStack Query for infinite scroll data fetching; TanStack Virtual for rendering the rows

Frequently Asked Questions

When should I add virtualisation to my list?

Add virtualisation when your list exceeds 200 - 500 items. Below that threshold the DOM overhead is manageable and virtualisation adds unnecessary complexity. Above it, you will typically see slow initial render, janky scrolling, and high memory usage. The exact threshold depends on item complexity.

Why must the scroll container have an explicit height?

TanStack Virtual calculates which items are visible based on the container's scrollHeight and clientHeight. If the container has no fixed height and grows to fit its content, all items are considered visible and virtualisation has no effect. Set an explicit height (e.g. height: 600px or h-screen) and overflow-y: auto.

How do I handle variable height items in TanStack Virtual?

Pass a measureElement function to useVirtualizer that returns the actual rendered height of each item. Attach ref={virtualizer.measureElement} and data-index={item.index} to each rendered item element. The virtualizer will measure items after render and recalculate positions automatically.

How do I programmatically scroll to an item?

Call virtualizer.scrollToIndex(index) to scroll a specific item into view. Pass { behavior: 'smooth' } for animated scrolling. This is commonly used for chat-scroll-to-bottom behaviour - call it after new messages are added to the list.

Can I use TanStack Virtual with TanStack Table?

Yes, they are designed to work together. Use useVirtualizer on the table's scroll container with the row count from the table instance. Map virtualizer.getVirtualItems() to render only the visible rows from table.getRowModel().rows. The TanStack documentation has a dedicated virtualised rows example.