Tanstack Virtual

by tanstack-skills6f5521ecbdfbNo license35 starsListed Oct 8, 2026Updated Oct 8, 2026Repository updated 8 months ago

Headless UI for virtualizing large element lists at 60FPS in TS/JS, React, Vue, Solid, Svelte, Lit & Angular.

Instructions onlySoftware Development
AI-generated overview

Reference guide for TanStack Virtual, covering list, grid, and window virtualization APIs and usage patterns.

What it does
This skill is a documentation-style reference for TanStack Virtual, a headless UI library that renders only visible items in large lists, grids, and tables. It explains installation, virtualizer options, the virtualizer API, and VirtualItem properties, with code examples for dynamic heights, horizontal lists, grids, window scrolling, infinite scrolling, sticky items, and smooth scrolling. It also lists best practices and common pitfalls.
When to use it
Use it when implementing or reviewing virtualized lists, grids, or tables in TypeScript or JavaScript frontends, especially React, Vue, Solid, Svelte, Lit, or Angular. It is also useful when debugging scroll jumps, blank rows, or dynamic item measurement issues.
Requirements
Requires the @tanstack/react-virtual package (and @tanstack/virtual-core for framework-agnostic use) installed via npm. Instructions only; no scripts are shipped.

Overview

TanStack Virtual provides virtualization logic for rendering only visible items in large lists, grids, and tables. It calculates which items are in the viewport and positions them with absolute positioning, keeping DOM node count minimal regardless of dataset size.

Package: @tanstack/react-virtual Core: @tanstack/virtual-core (framework-agnostic)

Installation

bash
npm install @tanstack/react-virtual

Core Pattern

tsx
import { useVirtualizer } from '@tanstack/react-virtual'
function VirtualList() {  const parentRef = useRef<HTMLDivElement>(null)
  const virtualizer = useVirtualizer({    count: 10000,    getScrollElement: () => parentRef.current,    estimateSize: () => 35, // estimated row height in px    overscan: 5,  })
  return (    <div ref={parentRef} style={{ height: '400px', overflow: 'auto' }}>      <div        style={{          height: `${virtualizer.getTotalSize()}px`,          width: '100%',          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)`,            }}          >            Row {virtualItem.index}          </div>        ))}      </div>    </div>  )}

Virtualizer Options

Required

OptionTypeDescription
countnumberTotal number of items
getScrollElement() => Element | nullReturns scroll container
estimateSize(index) => numberEstimated item size (overestimate recommended)

Optional

OptionTypeDefaultDescription
overscannumber1Extra items rendered beyond viewport
horizontalbooleanfalseHorizontal virtualization
gapnumber0Gap between items (px)
lanesnumber1Number of lanes (masonry/grid)
paddingStartnumber0Padding before first item
paddingEndnumber0Padding after last item
scrollPaddingStartnumber0Offset for scrollTo positioning
scrollPaddingEndnumber0Offset for scrollTo positioning
initialOffsetnumber0Starting scroll position
initialRectRect-Initial dimensions (SSR)
enabledbooleantrueEnable/disable
getItemKey(index) => Key(i) => iStable key for items
rangeExtractor(range) => number[]defaultCustom visible indices
scrollToFn(offset, options, instance) => voiddefaultCustom scroll behavior
measureElement(el, entry, instance) => numberdefaultCustom measurement
onChange(instance, sync) => void-State change callback
isScrollingResetDelaynumber150Delay before scroll complete

Virtualizer API

typescript
// Get visible itemsvirtualizer.getVirtualItems(): VirtualItem[]
// Get total scrollable sizevirtualizer.getTotalSize(): number
// Scroll to specific indexvirtualizer.scrollToIndex(index, { align: 'start' | 'center' | 'end' | 'auto', behavior: 'auto' | 'smooth' })
// Scroll to offsetvirtualizer.scrollToOffset(offset, options)
// Force recalculationvirtualizer.measure()

VirtualItem Properties

typescript
interface VirtualItem {  key: Key           // Unique key  index: number      // Index in source data  start: number      // Pixel offset (use for transform)  end: number        // End pixel offset  size: number       // Item dimension  lane: number       // Lane index (multi-column)}

Dynamic/Variable Heights

Use measureElement ref for items with unknown heights:

tsx
const virtualizer = useVirtualizer({  count: items.length,  getScrollElement: () => parentRef.current,  estimateSize: () => 50, // overestimate})
{virtualizer.getVirtualItems().map((virtualItem) => (  <div    key={virtualItem.key}    data-index={virtualItem.index}  // REQUIRED for measurement    ref={virtualizer.measureElement} // Attach for dynamic measurement    style={{      position: 'absolute',      top: 0,      left: 0,      width: '100%',      transform: `translateY(${virtualItem.start}px)`,      // Do NOT set fixed height - let content determine it    }}  >    {items[virtualItem.index].content}  </div>))}

Horizontal Virtualization

tsx
const virtualizer = useVirtualizer({  count: columns.length,  getScrollElement: () => parentRef.current,  estimateSize: () => 100,  horizontal: true,})
// Use width for container, translateX for positioning<div style={{ width: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>  {virtualizer.getVirtualItems().map((item) => (    <div style={{      position: 'absolute',      height: '100%',      width: `${item.size}px`,      transform: `translateX(${item.start}px)`,    }}>      Column {item.index}    </div>  ))}</div>

Grid Virtualization (Two Virtualizers)

tsx
function VirtualGrid() {  const parentRef = useRef<HTMLDivElement>(null)
  const rowVirtualizer = useVirtualizer({    count: 10000,    getScrollElement: () => parentRef.current,    estimateSize: () => 35,    overscan: 5,  })
  const columnVirtualizer = useVirtualizer({    count: 10000,    getScrollElement: () => parentRef.current,    estimateSize: () => 100,    horizontal: true,    overscan: 5,  })
  return (    <div ref={parentRef} style={{ height: '500px', width: '500px', overflow: 'auto' }}>      <div style={{        height: `${rowVirtualizer.getTotalSize()}px`,        width: `${columnVirtualizer.getTotalSize()}px`,        position: 'relative',      }}>        {rowVirtualizer.getVirtualItems().map((virtualRow) => (          <Fragment key={virtualRow.key}>            {columnVirtualizer.getVirtualItems().map((virtualColumn) => (              <div                key={virtualColumn.key}                style={{                  position: 'absolute',                  width: `${virtualColumn.size}px`,                  height: `${virtualRow.size}px`,                  transform: `translateX(${virtualColumn.start}px) translateY(${virtualRow.start}px)`,                }}              >                Cell {virtualRow.index},{virtualColumn.index}              </div>            ))}          </Fragment>        ))}      </div>    </div>  )}

Window Scrolling

tsx
import { useWindowVirtualizer } from '@tanstack/react-virtual'
function WindowList() {  const listRef = useRef<HTMLDivElement>(null)
  const virtualizer = useWindowVirtualizer({    count: 10000,    estimateSize: () => 45,    overscan: 5,    scrollMargin: listRef.current?.offsetTop ?? 0,  })
  return (    <div ref={listRef}>      <div style={{        height: `${virtualizer.getTotalSize()}px`,        position: 'relative',      }}>        {virtualizer.getVirtualItems().map((item) => (          <div            key={item.key}            style={{              position: 'absolute',              height: `${item.size}px`,              transform: `translateY(${item.start - virtualizer.options.scrollMargin}px)`,            }}          >            Row {item.index}          </div>        ))}      </div>    </div>  )}

Infinite Scrolling

tsx
import { useVirtualizer } from '@tanstack/react-virtual'import { useInfiniteQuery } from '@tanstack/react-query'
function InfiniteList() {  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({    queryKey: ['items'],    queryFn: ({ pageParam = 0 }) => fetchItems(pageParam),    getNextPageParam: (lastPage) => lastPage.nextCursor,  })
  const allItems = data?.pages.flatMap((page) => page.items) ?? []
  const virtualizer = useVirtualizer({    count: hasNextPage ? allItems.length + 1 : allItems.length,    getScrollElement: () => parentRef.current,    estimateSize: () => 50,    overscan: 5,  })
  useEffect(() => {    const items = virtualizer.getVirtualItems()    const lastItem = items[items.length - 1]    if (lastItem && lastItem.index >= allItems.length - 1 && hasNextPage && !isFetchingNextPage) {      fetchNextPage()    }  }, [virtualizer.getVirtualItems(), hasNextPage, isFetchingNextPage, allItems.length])
  // Render virtual items, show loader row for last item if loading}

Sticky Items

tsx
import { defaultRangeExtractor, Range } from '@tanstack/react-virtual'
const stickyIndexes = [0, 10, 20, 30] // Header indices
const virtualizer = useVirtualizer({  count: 1000,  getScrollElement: () => parentRef.current,  estimateSize: () => 50,  rangeExtractor: useCallback((range: Range) => {    const next = new Set([...stickyIndexes, ...defaultRangeExtractor(range)])    return [...next].sort((a, b) => a - b)  }, [stickyIndexes]),})
// Render sticky items with position: sticky; top: 0; zIndex: 1

Smooth Scrolling

tsx
const virtualizer = useVirtualizer({  scrollToFn: (offset, { behavior }, instance) => {    if (behavior === 'smooth') {      // Custom easing animation      instance.scrollElement?.scrollTo({ top: offset, behavior: 'smooth' })    } else {      instance.scrollElement?.scrollTo({ top: offset })    }  },})
// Usagevirtualizer.scrollToIndex(500, { align: 'center', behavior: 'smooth' })

Best Practices

  1. Overestimate estimateSize - prevents scroll jumps (items shrinking causes issues)
  2. Increase overscan (3-5) to reduce blank flashing during fast scrolling
  3. Use transform: translateY() over top for GPU-composited positioning
  4. Add data-index attribute when using measureElement for dynamic sizing
  5. Don't set fixed height on dynamically measured items
  6. Use getItemKey for stable keys when items can reorder
  7. Use gap option instead of margins (margins interfere with measurement)
  8. Use paddingStart/End instead of CSS padding on the container
  9. Use enabled: false to pause when the list is hidden
  10. Memoize callbacks (estimateSize, getItemKey, rangeExtractor)
  11. Use will-change: transform CSS on items for GPU acceleration

Common Pitfalls

  • Setting fixed height on dynamically measured items
  • Using CSS margins instead of the gap option
  • Forgetting data-index with measureElement
  • Not providing position: relative on the inner container
  • Underestimating estimateSize (causes scroll jumps)
  • Setting overscan too low for fast scrolling (blank items)
  • Forgetting to subtract scrollMargin from translateY in window scrolling
  • Not memoizing the estimateSize function (causes re-renders)

Source and attribution

Source:tanstack-skills/tanstack-skillsinplugins/tanstack-virtual/skills/tanstack-virtualat commit6f5521e

License: No license

Content belongs to its original authors. SourceWeft indexes it from a public repository.

Report or request removal