tanstack-virtual — Claude Code skill: virtualize big lists
A skill that shows the agent how to wire up TanStack Virtual so only visible rows render in long lists, tables and grids.
npx skills add tanstack-skills/tanstack-skills --skill tanstack-virtual -a claude-codeWhat tanstack-virtual does
The skill covers @tanstack/react-virtual and the framework-agnostic @tanstack/virtual-core. The virtualizer works out which items sit in the viewport and places them with absolute positioning, so the DOM node count stays low no matter how big the dataset gets.
Inside you get the base useVirtualizer pattern: required count, getScrollElement, estimateSize, plus optional knobs like overscan, lanes and getItemKey. Separate sections walk through variable heights with measureElement and the data-index attribute, horizontal scrolling, a two-virtualizer grid, and useWindowVirtualizer for window scrolling. The package installs with npm install @tanstack/react-virtual.
For dynamic heights the skill advises overestimating in estimateSize, otherwise rows jump while being measured. One more thing to watch: with measureElement you must not set a fixed height on the item, because the content decides the size.
What it helps with
- render a 10000-row list without dropped frames
- build a virtualized grid of rows and columns
- handle variable row heights through element measurement
- add horizontal scrolling to a long card strip
- move scrolling from an inner container to the browser window
Example prompts for the agent
- Set up useVirtualizer for a list of 10000 rows
- Build a virtualized grid with horizontal scrolling using TanStack Virtual
- My rows have different heights — wire up measureElement and data-index
How to install tanstack-virtual
1Skills CLI (Vercel)
npx skills add tanstack-skills/tanstack-skills --skill tanstack-virtual -a claude-code2Manually: put the files in place
~/.claude/skills/tanstack-virtual/SKILL.mdCopy the whole skill folder; for one project use .claude/skills/tanstack-virtual/
1Skills CLI (Vercel)
npx skills add tanstack-skills/tanstack-skills --skill tanstack-virtual -a codex2Manually: put the files in place
.agents/skills/tanstack-virtual/SKILL.mdFor all projects use ~/.agents/skills/
1Skills CLI (Vercel)
npx skills add tanstack-skills/tanstack-skills --skill tanstack-virtual -a cursor2Manually: put the files in place
.cursor/skills/tanstack-virtual/SKILL.mdCursor also reads .agents/skills/ and .claude/skills/
1GitHub CLI
gh skill install tanstack-skills/tanstack-skills tanstack-virtual2Manually: put the files in place
.github/skills/tanstack-virtual/SKILL.md1Skills CLI (Vercel)
npx skills add tanstack-skills/tanstack-skills --skill tanstack-virtual -a gemini-cli2Platform CLI
gemini skills install https://github.com/tanstack-skills/tanstack-skills --path plugins/tanstack-virtual/skills/tanstack-virtualManually: put the files in place
.devin/skills/tanstack-virtual/SKILL.mdThe legacy .windsurf/skills/ path still works
Skills CLI (Vercel)
npx skills add tanstack-skills/tanstack-skills --skill tanstack-virtual -a opencodeClaude.ai
Zip the folder and upload it in Settings → Capabilities → Skills
Step by step, with commands for each platform: How to install skills in Claude Code: five ways
Questions about tanstack-virtual
How do I install tanstack-virtual in Claude?
Run npx skills add tanstack-skills/tanstack-skills --skill tanstack-virtual -a claude-code. The skill file lands at ~/.claude/skills/tanstack-virtual/SKILL.md.
What does tanstack-virtual do?
It hands the agent ready-made TanStack Virtual patterns: a basic list, variable heights, horizontal scrolling, a grid and window scrolling.
Which useVirtualizer options are required?
count, getScrollElement and estimateSize. The rest are optional, and overscan defaults to 1.
Source text
SKILL.md · MIT
---
name: tanstack-virtual
description: Headless UI for virtualizing large element lists at 60FPS in TS/JS, React, Vue, Solid, Svelte, Lit & Angular.
---
## 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
| Option | Type | Description |
|--------|------|-------------|
| `count` | `number` | Total number of items |
| `getScrollElement` | `() => Element \| null` | Returns scroll container |
| `estimateSize` | `(index) => number` | Estimated item size (overestimate recommended) |
### Optional
| 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 |
## Virtualizer API
```typescript
// 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()
```
## 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 })
}
},
})
// Usage
virtualizer.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 · MIT · tanstack-skills/tanstack-skills