
Tanstack Virtual
- 102 installs
- 14 repo stars
- Updated March 2, 2026
- oakoss/agent-skills
Helps with ai & agent building tasks during AI-assisted development.
About
tanstack-virtual is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- tanstack-virtual
- AI & Agent Building
- AI-coding skill
Tanstack Virtual by the numbers
- 102 all-time installs (skills.sh)
- +2 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #4,307 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/oakoss/agent-skills --skill tanstack-virtualAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 102 |
|---|---|
| repo stars | ★ 14 |
| Last updated | March 2, 2026 |
| Repository | oakoss/agent-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
TanStack Virtual
Overview
TanStack Virtual is a headless UI utility for virtualizing large lists, grids, and tables. It renders only the visible items in the viewport, dramatically reducing DOM nodes and improving performance for datasets with thousands of rows. Framework adapters are available for React, Vue, Solid, Svelte, Lit, and Angular.
When to use: Rendering thousands of rows or columns, building virtualized tables, implementing infinite scroll, displaying large datasets where DOM node count impacts performance.
When NOT to use: Small lists under ~100 items (no performance benefit), server-rendered static content, layouts where all items must be in the DOM for SEO or accessibility, simple pagination (render one page at a time instead).
Quick Reference
| Pattern | API | Key Points |
|---|---|---|
| Vertical list | useVirtualizer({ count, getScrollElement, estimateSize }) | Wrap items in absolute-positioned container |
| Horizontal list | useVirtualizer({ horizontal: true, ... }) | Use getTotalSize() for width instead of height |
| Grid layout | Row virtualizer + column virtualizer | Two virtualizer instances sharing one scroll element |
| Dynamic sizing | ref={virtualizer.measureElement} | Set data-index on each element |
| Window scroller | useWindowVirtualizer({ count, estimateSize }) | No getScrollElement needed |
| Scroll to item | virtualizer.scrollToIndex(index) | Supports `align: 'start' \ |
| Scroll to offset | virtualizer.scrollToOffset(px) | Supports align and behavior: 'smooth' |
| Custom keys | getItemKey: (index) => items[index].id | Stable keys improve measurement cache |
| Overscan | overscan: 5 | Renders extra items outside viewport (default: 1) |
| Gap between items | gap: 8 | Space between virtualized items in pixels |
| Multi-lane layout | lanes: 3 | Masonry-style column layouts |
| Padding | paddingStart: 100, paddingEnd: 100 | Space before first and after last item |
| RTL support | isRtl: true | Right-to-left horizontal scrolling |
| Range extractor | rangeExtractor: (range) => [...] | Customize rendered indices (sticky headers, footers) |
| Disable virtualizer | enabled: false | Renders nothing, resets internal state |
| Force remeasure | virtualizer.measure() | Call after external layout changes |
Common Mistakes
| Mistake | Correct Pattern |
|---|---|
Missing overflow: auto on scroll container | Parent element must have overflow: auto and a fixed height/width |
Using virtualItem.index as React key | Use virtualItem.key which accounts for dynamic reordering |
Forgetting position: relative on inner container | The total-size container must be position: relative |
Not setting data-index with measureElement | Dynamic measurement requires data-index={virtualItem.index} on each element |
Setting estimateSize too small for dynamic items | Overestimate to prevent scroll jumping; use largest expected size |
Recreating getScrollElement on every render | Use a ref callback: () => parentRef.current |
Not using getTotalSize() for container dimensions | Inner container height/width must equal virtualizer.getTotalSize() |
Absolute positioning without transform | Use transform: translateY(item.start) for GPU-accelerated positioning |
Using contain: strict without fixed dimensions | contain: strict requires explicit width and height on the scroll container |
| Using with React Compiler without opting out | Add 'use no memo' directive to components using useVirtualizer — interior mutability breaks memoization |
Delegation
If the tanstack-table skill is available, delegate data table virtualization to it. TanStack Table has built-in virtualization integration.Otherwise, recommend: npx skills add oakoss/agent-skills --skill tanstack-table>
If the tanstack-query skill is available, delegate data fetching and infinite query patterns to it.Otherwise, recommend: npx skills add oakoss/agent-skills --skill tanstack-queryReferences
- List virtualization with useVirtualizer
- Grid virtualization with row and column virtualizers
- Dynamic sizing with measureElement
- Infinite scroll with TanStack Query integration
Dynamic Sizing
measureElement for Auto-Sized Rows
When item heights depend on content, use measureElement as the ref callback to measure each item after rendering.
import { useVirtualizer } from '@tanstack/react-virtual';
function DynamicList({ sentences }: { sentences: string[] }) {
const parentRef = React.useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: sentences.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50,
overscan: 5,
});
return (
<div
ref={parentRef}
style={{
height: '400px',
overflow: 'auto',
contain: 'strict',
}}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualizer.getVirtualItems()[0]?.start ?? 0}px)`,
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
>
<div style={{ padding: '10px 0' }}>
{sentences[virtualRow.index]}
</div>
</div>
))}
</div>
</div>
</div>
);
}Key requirements for measureElement:
- Set
data-index={virtualRow.index}on the measured element - Pass
virtualizer.measureElementas therefcallback - The element must be in the DOM when measured (no conditional rendering of the ref target)
This example uses a wrapper div pattern where items are positioned as a group using translateY of the first item's start offset, rather than positioning each item individually. Both patterns work.
Overestimate for Smooth Scrolling
estimateSize provides the initial size before measurement. Setting it too low causes scroll jumping as items expand after measurement.
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 100,
overscan: 5,
});Use the largest expected item size as the estimate. When items shrink after measurement, scrolling feels natural. When items grow after measurement, the scrollbar jumps.
Variable Heights with Known Sizes
When sizes are known upfront (not from content measurement), pass them directly to estimateSize:
const rowHeights = items.map((item) =>
item.type === 'header' ? 60 : item.type === 'expanded' ? 120 : 40,
);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: (index) => rowHeights[index],
});This is faster than measureElement because no DOM measurement is needed.
Force Remeasure After Layout Changes
When external factors change item sizes (window resize, sidebar toggle, font loading), call measure() to invalidate cached measurements:
function ResizableList({ items }: { items: string[] }) {
const parentRef = React.useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50,
});
React.useEffect(() => {
const observer = new ResizeObserver(() => {
virtualizer.measure();
});
if (parentRef.current) {
observer.observe(parentRef.current);
}
return () => observer.disconnect();
}, [virtualizer]);
return (
<div ref={parentRef} style={{ height: '100%', overflow: 'auto' }}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{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]}
</div>
))}
</div>
</div>
);
}Expandable Rows
When rows toggle between collapsed and expanded states, the virtualizer remeasures automatically through measureElement:
function ExpandableList({
items,
}: {
items: { id: string; title: string; details: string }[];
}) {
const parentRef = React.useRef<HTMLDivElement>(null);
const [expandedIds, setExpandedIds] = React.useState<Set<string>>(new Set());
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 60,
getItemKey: (index) => items[index].id,
});
const toggleExpanded = (id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
return (
<div ref={parentRef} style={{ height: '400px', overflow: 'auto' }}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const item = items[virtualItem.index];
const isExpanded = expandedIds.has(item.id);
return (
<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)`,
}}
>
<button onClick={() => toggleExpanded(item.id)}>
{item.title}
</button>
{isExpanded ? <div>{item.details}</div> : null}
</div>
);
})}
</div>
</div>
);
}Using getItemKey with stable IDs ensures measurement caches persist correctly when items expand or collapse, avoiding size flickering during re-renders.
Performance Tips for Dynamic Sizing
| Tip | Why |
|---|---|
Use contain: strict on scroll container | Isolates layout recalculations to the container |
Set overflowAnchor: 'none' on scroll container | Prevents browser scroll anchoring from conflicting with virtual positioning |
Increase overscan for dynamic content | Extra off-screen items prevent visible blank areas during fast scrolling |
Use getItemKey with stable IDs | Measurement cache survives list reorders |
Avoid measuring inside useEffect | Let measureElement handle timing via ref callbacks |
Grid Virtualization
Basic Grid with Two Virtualizers
Grids require two useVirtualizer instances -- one for rows (vertical) and one for columns (horizontal) -- sharing the same scroll element.
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualGrid({
data,
rowCount,
columnCount,
}: {
data: string[][];
rowCount: number;
columnCount: number;
}) {
const parentRef = React.useRef<HTMLDivElement>(null);
const rowVirtualizer = useVirtualizer({
count: rowCount,
getScrollElement: () => parentRef.current,
estimateSize: () => 50,
overscan: 5,
});
const columnVirtualizer = useVirtualizer({
horizontal: true,
count: columnCount,
getScrollElement: () => parentRef.current,
estimateSize: () => 150,
overscan: 5,
});
return (
<div
ref={parentRef}
style={{ height: '500px', width: '800px', overflow: 'auto' }}
>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: `${columnVirtualizer.getTotalSize()}px`,
position: 'relative',
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => (
<React.Fragment key={virtualRow.key}>
{columnVirtualizer.getVirtualItems().map((virtualColumn) => (
<div
key={virtualColumn.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: `${virtualColumn.size}px`,
height: `${virtualRow.size}px`,
transform: `translateX(${virtualColumn.start}px) translateY(${virtualRow.start}px)`,
}}
>
{data[virtualRow.index][virtualColumn.index]}
</div>
))}
</React.Fragment>
))}
</div>
</div>
);
}The inner container dimensions use both rowVirtualizer.getTotalSize() for height and columnVirtualizer.getTotalSize() for width. Each cell is positioned with both translateX and translateY.
Grid with Variable Cell Sizes
function VariableGrid({
data,
rowSizes,
columnSizes,
}: {
data: string[][];
rowSizes: number[];
columnSizes: number[];
}) {
const parentRef = React.useRef<HTMLDivElement>(null);
const rowVirtualizer = useVirtualizer({
count: rowSizes.length,
getScrollElement: () => parentRef.current,
estimateSize: (i) => rowSizes[i],
overscan: 5,
});
const columnVirtualizer = useVirtualizer({
horizontal: true,
count: columnSizes.length,
getScrollElement: () => parentRef.current,
estimateSize: (i) => columnSizes[i],
overscan: 5,
});
return (
<div
ref={parentRef}
style={{ height: '500px', width: '800px', overflow: 'auto' }}
>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: `${columnVirtualizer.getTotalSize()}px`,
position: 'relative',
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => (
<React.Fragment key={virtualRow.key}>
{columnVirtualizer.getVirtualItems().map((virtualColumn) => (
<div
key={virtualColumn.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: `${columnSizes[virtualColumn.index]}px`,
height: `${rowSizes[virtualRow.index]}px`,
transform: `translateX(${virtualColumn.start}px) translateY(${virtualRow.start}px)`,
}}
>
{data[virtualRow.index][virtualColumn.index]}
</div>
))}
</React.Fragment>
))}
</div>
</div>
);
}When sizes are known ahead of time, pass them directly via estimateSize. This avoids the need for measureElement and prevents layout shifts.
Dynamic Grid with measureElement
When cell sizes are determined by content, use measureElement with custom indexAttribute values to distinguish row and column indices.
function DynamicGrid({ data }: { data: string[][] }) {
const parentRef = React.useRef<HTMLDivElement>(null);
const rowVirtualizer = useVirtualizer({
count: data.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50,
indexAttribute: 'data-row-index',
});
const columnVirtualizer = useVirtualizer({
horizontal: true,
count: data[0].length,
getScrollElement: () => parentRef.current,
estimateSize: () => 150,
indexAttribute: 'data-column-index',
});
return (
<div
ref={parentRef}
style={{ height: '500px', width: '800px', overflow: 'auto' }}
>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: `${columnVirtualizer.getTotalSize()}px`,
position: 'relative',
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => (
<React.Fragment key={virtualRow.key}>
{columnVirtualizer.getVirtualItems().map((virtualColumn) => (
<div
key={virtualColumn.key}
data-row-index={virtualRow.index}
data-column-index={virtualColumn.index}
ref={(el) => {
rowVirtualizer.measureElement(el);
columnVirtualizer.measureElement(el);
}}
style={{
position: 'absolute',
top: 0,
left: 0,
width: `${virtualColumn.size}px`,
height: `${virtualRow.size}px`,
transform: `translateX(${virtualColumn.start}px) translateY(${virtualRow.start}px)`,
}}
>
{data[virtualRow.index][virtualColumn.index]}
</div>
))}
</React.Fragment>
))}
</div>
</div>
);
}The indexAttribute option tells each virtualizer which data attribute to read for index lookup. The default is data-index, but grids need separate attributes so each virtualizer reads the correct axis.
Programmatic Scroll in Grids
function scrollToCell(
rowVirtualizer: Virtualizer<HTMLDivElement, Element>,
columnVirtualizer: Virtualizer<HTMLDivElement, Element>,
rowIndex: number,
columnIndex: number,
) {
rowVirtualizer.scrollToIndex(rowIndex, { align: 'start' });
columnVirtualizer.scrollToIndex(columnIndex, { align: 'start' });
}Call both virtualizers to scroll to a specific cell. The scroll operations happen on the same element and combine naturally.
Grid with Fixed Header Row
function GridWithHeader({
headers,
data,
}: {
headers: string[];
data: string[][];
}) {
const parentRef = React.useRef<HTMLDivElement>(null);
const rowVirtualizer = useVirtualizer({
count: data.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 40,
overscan: 5,
});
const columnVirtualizer = useVirtualizer({
horizontal: true,
count: headers.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 150,
overscan: 3,
});
return (
<div
ref={parentRef}
style={{ height: '500px', width: '100%', overflow: 'auto' }}
>
<div style={{ position: 'sticky', top: 0, zIndex: 1, display: 'flex' }}>
{columnVirtualizer.getVirtualItems().map((virtualColumn) => (
<div
key={virtualColumn.key}
style={{
width: `${virtualColumn.size}px`,
flexShrink: 0,
fontWeight: 'bold',
borderBottom: '2px solid #ccc',
padding: '8px',
}}
>
{headers[virtualColumn.index]}
</div>
))}
</div>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: `${columnVirtualizer.getTotalSize()}px`,
position: 'relative',
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => (
<React.Fragment key={virtualRow.key}>
{columnVirtualizer.getVirtualItems().map((virtualColumn) => (
<div
key={virtualColumn.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: `${virtualColumn.size}px`,
height: `${virtualRow.size}px`,
transform: `translateX(${virtualColumn.start}px) translateY(${virtualRow.start}px)`,
padding: '8px',
borderBottom: '1px solid #eee',
}}
>
{data[virtualRow.index][virtualColumn.index]}
</div>
))}
</React.Fragment>
))}
</div>
</div>
);
}Use position: sticky for the header row so it stays visible while the virtualized body scrolls underneath.
Infinite Scroll
Basic Infinite Scroll with TanStack Query
Combine useInfiniteQuery for data fetching with useVirtualizer for rendering. Detect when the user scrolls near the end and fetch the next page.
import { useInfiniteQuery } from '@tanstack/react-query';
import { useVirtualizer } from '@tanstack/react-virtual';
interface Page {
items: { id: string; name: string }[];
nextCursor: string | null;
}
function InfiniteList() {
const parentRef = React.useRef<HTMLDivElement>(null);
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey: ['items'],
queryFn: ({ pageParam }): Promise<Page> =>
fetch(`/api/items?cursor=${pageParam}`).then((r) => r.json()),
initialPageParam: '',
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,
});
const virtualItems = virtualizer.getVirtualItems();
React.useEffect(() => {
const lastItem = virtualItems[virtualItems.length - 1];
if (!lastItem) return;
if (
lastItem.index >= allItems.length - 1 &&
hasNextPage &&
!isFetchingNextPage
) {
fetchNextPage();
}
}, [
virtualItems,
allItems.length,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
]);
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualItems.map((virtualItem) => {
const isLoaderRow = virtualItem.index > allItems.length - 1;
return (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
{isLoaderRow
? hasNextPage
? 'Loading more...'
: 'Nothing more to load'
: allItems[virtualItem.index].name}
</div>
);
})}
</div>
</div>
);
}The count includes an extra item (allItems.length + 1) when more pages exist. This loader row triggers fetchNextPage when it becomes visible.
Intersection Observer Approach
Use IntersectionObserver instead of checking virtual item indices for more precise scroll detection.
function InfiniteListWithObserver() {
const parentRef = React.useRef<HTMLDivElement>(null);
const loadMoreRef = React.useRef<HTMLDivElement>(null);
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey: ['items'],
queryFn: ({ pageParam }): Promise<Page> =>
fetch(`/api/items?cursor=${pageParam}`).then((r) => r.json()),
initialPageParam: '',
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
const allItems = data?.pages.flatMap((page) => page.items) ?? [];
const virtualizer = useVirtualizer({
count: allItems.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50,
overscan: 5,
});
React.useEffect(() => {
const el = loadMoreRef.current;
if (!el) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ rootMargin: '200px' },
);
observer.observe(el);
return () => observer.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
return (
<div ref={parentRef} style={{ height: '600px', 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)`,
}}
>
{allItems[virtualItem.index].name}
</div>
))}
</div>
<div ref={loadMoreRef}>{isFetchingNextPage ? 'Loading...' : null}</div>
</div>
);
}The rootMargin: '200px' triggers fetching before the sentinel element is visible, creating a smoother experience.
Bidirectional Infinite Scroll
Load pages in both directions by combining fetchNextPage and fetchPreviousPage.
function BidirectionalList() {
const parentRef = React.useRef<HTMLDivElement>(null);
const {
data,
fetchNextPage,
fetchPreviousPage,
hasNextPage,
hasPreviousPage,
isFetchingNextPage,
isFetchingPreviousPage,
} = useInfiniteQuery({
queryKey: ['timeline'],
queryFn: ({ pageParam }): Promise<Page> =>
fetch(
`/api/timeline?cursor=${pageParam.cursor}&direction=${pageParam.direction}`,
).then((r) => r.json()),
initialPageParam: { cursor: '', direction: 'forward' as const },
getNextPageParam: (lastPage) =>
lastPage.nextCursor
? { cursor: lastPage.nextCursor, direction: 'forward' as const }
: undefined,
getPreviousPageParam: (firstPage) =>
firstPage.previousCursor
? { cursor: firstPage.previousCursor, direction: 'backward' as const }
: undefined,
});
const allItems = data?.pages.flatMap((page) => page.items) ?? [];
const virtualizer = useVirtualizer({
count: allItems.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50,
overscan: 5,
});
const virtualItems = virtualizer.getVirtualItems();
React.useEffect(() => {
if (!virtualItems.length) return;
const firstItem = virtualItems[0];
const lastItem = virtualItems[virtualItems.length - 1];
if (firstItem.index <= 1 && hasPreviousPage && !isFetchingPreviousPage) {
fetchPreviousPage();
}
if (
lastItem.index >= allItems.length - 2 &&
hasNextPage &&
!isFetchingNextPage
) {
fetchNextPage();
}
}, [
virtualItems,
allItems.length,
hasNextPage,
hasPreviousPage,
isFetchingNextPage,
isFetchingPreviousPage,
fetchNextPage,
fetchPreviousPage,
]);
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
{isFetchingPreviousPage ? <div>Loading previous...</div> : null}
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualItems.map((virtualItem) => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
{allItems[virtualItem.index].name}
</div>
))}
</div>
{isFetchingNextPage ? <div>Loading more...</div> : null}
</div>
);
}Loading States
| State | Display |
|---|---|
isFetchingNextPage | Spinner or skeleton at bottom |
isFetchingPreviousPage | Spinner or skeleton at top |
!hasNextPage | "End of list" message |
isLoading (initial) | Full-page skeleton or spinner |
isError | Error message with retry button |
Performance Considerations
| Concern | Solution |
|---|---|
| Flattening pages on every render | Memoize allItems with useMemo |
| Too many DOM nodes from loaded pages | Virtual count grows with data; only visible items are rendered |
| Memory from accumulated pages | Use maxPages option on useInfiniteQuery to cap stored pages |
| Scroll position jumps on prepend | Use scrollMargin or maintain scroll position manually |
List Virtualization
Basic Vertical List
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualList({ items }: { items: string[] }) {
const parentRef = React.useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 35,
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)`,
}}
>
{items[virtualItem.index]}
</div>
))}
</div>
</div>
);
}The three-layer DOM structure is required:
1. Scroll container (parentRef) -- fixed height, overflow: auto 2. Inner container -- height set to getTotalSize(), position: relative 3. Virtual items -- absolute positioned, translated to virtualItem.start
Horizontal List
function HorizontalList({ items }: { items: string[] }) {
const parentRef = React.useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
horizontal: true,
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 120,
overscan: 5,
});
return (
<div ref={parentRef} style={{ width: '600px', overflow: 'auto' }}>
<div
style={{
width: `${virtualizer.getTotalSize()}px`,
height: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
height: '100%',
width: `${virtualItem.size}px`,
transform: `translateX(${virtualItem.start}px)`,
}}
>
{items[virtualItem.index]}
</div>
))}
</div>
</div>
);
}For horizontal lists, swap height for width in the inner container and use translateX instead of translateY.
Window Scroller
useWindowVirtualizer uses the browser window as the scroll container instead of a specific element.
import { useWindowVirtualizer } from '@tanstack/react-virtual';
function WindowVirtualList({ items }: { items: string[] }) {
const virtualizer = useWindowVirtualizer({
count: items.length,
estimateSize: () => 45,
overscan: 5,
});
return (
<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)`,
}}
>
{items[virtualItem.index]}
</div>
))}
</div>
);
}No getScrollElement is needed -- the hook attaches to the window automatically.
Scroll to Index
function ScrollableList({ items }: { items: string[] }) {
const parentRef = React.useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 35,
});
return (
<div>
<button onClick={() => virtualizer.scrollToIndex(0)}>Top</button>
<button onClick={() => virtualizer.scrollToIndex(items.length - 1)}>
Bottom
</button>
<button
onClick={() =>
virtualizer.scrollToIndex(Math.floor(items.length / 2), {
align: 'center',
behavior: 'smooth',
})
}
>
Middle
</button>
<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)`,
}}
>
{items[virtualItem.index]}
</div>
))}
</div>
</div>
</div>
);
}The align option controls where the target item appears: 'start', 'center', 'end', or 'auto' (default, scrolls minimum distance).
Stable Item Keys
When items can be reordered or the list changes, provide stable keys for measurement caching:
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 35,
getItemKey: (index) => items[index].id,
});Virtualizer Options Reference
| Option | Type | Default | Description |
|---|---|---|---|
count | number | required | Total number of items |
getScrollElement | `() => Element \ | null` | required |
estimateSize | (index: number) => number | required | Estimated item size in pixels |
overscan | number | 1 | Extra items rendered outside viewport |
horizontal | boolean | false | Enable horizontal orientation |
paddingStart | number | 0 | Padding before first item |
paddingEnd | number | 0 | Padding after last item |
gap | number | 0 | Space between items |
lanes | number | 1 | Multi-column lane count |
enabled | boolean | true | Enable or disable the virtualizer |
isRtl | boolean | false | Right-to-left layout |
getItemKey | (index: number) => Key | (i) => i | Stable key for each item |
rangeExtractor | (range: Range) => number[] | built-in | Customize which indices to render |
scrollMargin | number | 0 | Margin for scroll positioning |
initialOffset | number | 0 | Starting scroll position |
VirtualItem Properties
| Property | Type | Description |
|---|---|---|
key | Key | Unique key for React rendering |
index | number | Index in the original list |
start | number | Pixel offset from container start |
end | number | Pixel offset of item end |
size | number | Measured or estimated item size |
lane | number | Lane index for multi-lane layouts |