tanstack-virtual by tanstack-skills/tanstack-skills
npx skills add https://github.com/tanstack-skills/tanstack-skills --skill tanstack-virtualTanStack Virtual 为大型列表、网格和表格提供虚拟化逻辑,仅渲染可见项。它计算视口中哪些项可见,并使用绝对定位进行定位,无论数据集大小如何,都能保持 DOM 节点数量最少。
包: @tanstack/react-virtual 核心: @tanstack/virtual-core(框架无关)
npm install @tanstack/react-virtual
import { useVirtualizer } from '@tanstack/react-virtual'
function VirtualList() {
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: 10000,
getScrollElement: () => parentRef.current,
estimateSize: () => 35, // 预估行高(单位: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)`,
}}
>
行 {virtualItem.index}
</div>
))}
</div>
</div>
)
}
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
| 选项 | 类型 | 描述 |
|---|---|---|
count | number | 总项数 |
getScrollElement | `() => Element | null` |
estimateSize | (index) => number | 预估项大小(建议高估) |
| 选项 | 类型 | 默认值 | 描述 |
|---|---|---|---|
overscan | number | 1 | 视口外额外渲染的项数 |
horizontal | boolean | false | 水平虚拟化 |
gap | number | 0 | 项之间的间隔(px) |
lanes | number | 1 | 列数(瀑布流/网格) |
paddingStart | number | 0 | 第一项之前的填充 |
paddingEnd | number | 0 | 最后一项之后的填充 |
scrollPaddingStart | number | 0 | scrollTo 定位的起始偏移量 |
scrollPaddingEnd | number | 0 | scrollTo 定位的结束偏移量 |
initialOffset | number | 0 | 起始滚动位置 |
initialRect | Rect | - | 初始尺寸(SSR) |
enabled | boolean | true | 启用/禁用 |
getItemKey | (index) => Key | (i) => i | 项的稳定键 |
rangeExtractor | (range) => number[] | default | 自定义可见索引 |
scrollToFn | (offset, options, instance) => void | default | 自定义滚动行为 |
measureElement | (el, entry, instance) => number | default | 自定义测量 |
onChange | (instance, sync) => void | - | 状态变化回调 |
isScrollingResetDelay | number | 150 | 滚动完成前的延迟 |
// 获取可见项
virtualizer.getVirtualItems(): VirtualItem[]
// 获取总可滚动大小
virtualizer.getTotalSize(): number
// 滚动到特定索引
virtualizer.scrollToIndex(index, { align: 'start' | 'center' | 'end' | 'auto', behavior: 'auto' | 'smooth' })
// 滚动到偏移量
virtualizer.scrollToOffset(offset, options)
// 强制重新计算
virtualizer.measure()
interface VirtualItem {
key: Key // 唯一键
index: number // 源数据中的索引
start: number // 像素偏移量(用于 transform)
end: number // 结束像素偏移量
size: number // 项尺寸
lane: number // 列索引(多列)
}
使用 measureElement ref 来处理高度未知的项:
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50, // 高估
})
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
data-index={virtualItem.index} // 用于测量,必需
ref={virtualizer.measureElement} // 附加以进行动态测量
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
// 不要设置固定高度 - 让内容决定高度
}}
>
{items[virtualItem.index].content}
</div>
))}
const virtualizer = useVirtualizer({
count: columns.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 100,
horizontal: true,
})
// 对容器使用 width,对定位使用 translateX
<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)`,
}}>
列 {item.index}
</div>
))}
</div>
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)`,
}}
>
单元格 {virtualRow.index},{virtualColumn.index}
</div>
))}
</Fragment>
))}
</div>
</div>
)
}
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)`,
}}
>
行 {item.index}
</div>
))}
</div>
</div>
)
}
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])
// 渲染虚拟项,如果正在加载,为最后一项显示加载行
}
import { defaultRangeExtractor, Range } from '@tanstack/react-virtual'
const stickyIndexes = [0, 10, 20, 30] // 标题索引
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]),
})
// 使用 position: sticky; top: 0; zIndex: 1 渲染粘性项
const virtualizer = useVirtualizer({
scrollToFn: (offset, { behavior }, instance) => {
if (behavior === 'smooth') {
// 自定义缓动动画
instance.scrollElement?.scrollTo({ top: offset, behavior: 'smooth' })
} else {
instance.scrollElement?.scrollTo({ top: offset })
}
},
})
// 用法
virtualizer.scrollToIndex(500, { align: 'center', behavior: 'smooth' })
estimateSize - 防止滚动跳动(项收缩会导致问题)overscan(3-5)以减少快速滚动时的空白闪烁transform: translateY() 而不是 top 进行 GPU 合成定位measureElement 进行动态大小时,添加 data-index 属性getItemKey 获取稳定键gap 选项 而不是边距(边距会干扰测量)paddingStart/End 而不是容器上的 CSS 填充enabled: false 在列表隐藏时暂停estimateSize, getItemKey, rangeExtractor)will-change: transform CSS 在项上实现 GPU 加速gap 选项measureElement 时忘记 data-indexposition: relativeestimateSize(导致滚动跳动)overscan 设置过低(出现空白项)translateY 中减去 scrollMarginestimateSize 函数(导致重新渲染)每周安装量
110
代码仓库
GitHub 星标数
5
首次出现
2026年2月21日
安全审计
已安装于
codex106
opencode105
gemini-cli104
cursor104
github-copilot103
amp103
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)
npm install @tanstack/react-virtual
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>
)
}
| Option | Type | Description |
|---|---|---|
count | number | Total number of items |
getScrollElement | `() => Element | null` |
estimateSize | (index) => number | Estimated item size (overestimate recommended) |
| Option | Type | Default | Description |
|---|---|---|---|
overscan | number | 1 | Extra items rendered beyond viewport |
horizontal | boolean | false | Horizontal virtualization |
gap |
// Get visible items
virtualizer.getVirtualItems(): VirtualItem[]
// Get total scrollable size
virtualizer.getTotalSize(): number
// Scroll to specific index
virtualizer.scrollToIndex(index, { align: 'start' | 'center' | 'end' | 'auto', behavior: 'auto' | 'smooth' })
// Scroll to offset
virtualizer.scrollToOffset(offset, options)
// Force recalculation
virtualizer.measure()
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)
}
Use measureElement ref for items with unknown heights:
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>
))}
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>
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>
)
}
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>
)
}
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
}
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
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 })
}
},
})
// Usage
virtualizer.scrollToIndex(500, { align: 'center', behavior: 'smooth' })
estimateSize - prevents scroll jumps (items shrinking causes issues)overscan (3-5) to reduce blank flashing during fast scrollingtransform: translateY() over top for GPU-composited positioningdata-index attribute when using measureElement for dynamic sizinggetItemKey for stable keys when items can reordergap option instead of margins (margins interfere with measurement)gap optiondata-index with measureElementposition: relative on the inner containerestimateSize (causes scroll jumps)overscan too low for fast scrolling (blank items)scrollMargin from translateY in window scrollingestimateSize function (causes re-renders)Weekly Installs
110
Repository
GitHub Stars
5
First Seen
Feb 21, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
codex106
opencode105
gemini-cli104
cursor104
github-copilot103
amp103
Tailwind CSS v4 + shadcn/ui 生产级技术栈配置指南与最佳实践
2,600 周安装
number |
0 |
| Gap between items (px) |
lanes | number | 1 | Number of lanes (masonry/grid) |
paddingStart | number | 0 | Padding before first item |
paddingEnd | number | 0 | Padding after last item |
scrollPaddingStart | number | 0 | Offset for scrollTo positioning |
scrollPaddingEnd | number | 0 | Offset for scrollTo positioning |
initialOffset | number | 0 | Starting scroll position |
initialRect | Rect | - | Initial dimensions (SSR) |
enabled | boolean | true | Enable/disable |
getItemKey | (index) => Key | (i) => i | Stable key for items |
rangeExtractor | (range) => number[] | default | Custom visible indices |
scrollToFn | (offset, options, instance) => void | default | Custom scroll behavior |
measureElement | (el, entry, instance) => number | default | Custom measurement |
onChange | (instance, sync) => void | - | State change callback |
isScrollingResetDelay | number | 150 | Delay before scroll complete |
paddingStart/End instead of CSS padding on the containerenabled: false to pause when the list is hiddenestimateSize, getItemKey, rangeExtractor)will-change: transform CSS on items for GPU acceleration