Tanstack Virtual

作者 tanstack-skills6f5521ecbdfb无许可证35 个星标收录于 2026年10月8日更新于 2026年10月8日仓库8个月前更新

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

AI 生成的概览

TanStack Virtual 参考指南,涵盖列表、网格和窗口虚拟化的 API 与用法模式。

功能
该技能是 TanStack Virtual 的文档式参考,这是一个无头 UI 库,只渲染大型列表、网格和表格中的可见项。它说明安装方式、虚拟化器选项、虚拟化器 API 和 VirtualItem 属性,并提供动态高度、横向列表、网格、窗口滚动、无限滚动、粘性项和平滑滚动的代码示例。它还列出最佳实践和常见陷阱。
适用场景
在 TypeScript 或 JavaScript 前端中实现或审查虚拟化列表、网格或表格时使用,尤其是 React、Vue、Solid、Svelte、Lit 或 Angular。调试滚动跳动、空白行或动态项测量问题时也适用。
运行要求
需要通过 npm 安装 @tanstack/react-virtual 包(框架无关用法还需 @tanstack/virtual-core)。仅为说明文档,不附带脚本。

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)

来源与署名

来源:tanstack-skills/tanstack-skills位于plugins/tanstack-virtual/skills/tanstack-virtual提交6f5521e

许可证: 无许可证

内容归原作者所有。SourceWeft 从公开仓库中收录这些内容。

举报或申请下架