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 從公開儲存庫中收錄這些內容。

檢舉或申請下架