Help us improve
Share bugs, ideas, or general feedback.
From tanstack-virtual
Virtualizes large lists, grids, and tables at 60FPS by rendering only visible items with headless UI for TS/JS, React, Vue, Solid, Svelte, Lit, Angular.
npx claudepluginhub tanstack-skills/tanstack-skills --plugin tanstack-virtualHow this skill is triggered — by the user, by Claude, or both
Slash command
/tanstack-virtual:tanstack-virtualThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
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.
Virtualizes MUI lists with react-window FixedSizeList/VariableSizeList, react-virtuoso, and Autocomplete patterns for rendering 1000+ items without layout thrashing or memory issues.
Optimizes large lists with virtual scrolling/windowing. Supports react-window, TanStack Virtual, variable-height rows, infinite scroll, and grid virtualization.
Placeholder skill for react-virtuoso. Once authored, will guide Claude on implementing virtualized lists with react-virtuoso in React apps.
Share bugs, ideas, or general feedback.
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 | Returns scroll container |
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 | 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 |
// 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)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 accelerationgap 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)