Virtualized Lists skill for Claude Code
Thousands of rows, a few dozen DOM nodes. Claude measures before it virtualizes. It tries cheaper rows, pagination and CSS content-visibility first, then builds TanStack Virtual, react-window 2 or react-virtuoso lists with stable keys, measured heights, infinite loading and sticky headers. It keeps focus and screen reader positions intact, adds search where find-in-page breaks, and restores scroll position on back navigation.
- name
virtualized-lists - size284 lines · ≈3,200 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Renders very long lists, feeds and grids without jank — deciding between pagination, CSS content-visibility and true windowing, then implementing TanStack Virtual, react-window 2 or react-virtuoso with fixed or measured row heights, window scrolling, infinite loading, sticky group headers, scroll restoration on back navigation, and accessible semantics (aria-setsize and aria-posinset, feed pattern, focus retention, find-in-page limits). Use when a list of thousands of items scrolls slowly, the DOM node count is huge, building an infinite feed, chat log, log viewer or masonry grid, or when a virtual list jumps, flashes blank rows or loses scroll position. Try asking
- This list of 20,000 products scrolls terribly. Make it fast
- Build an infinite activity feed that keeps its scroll position when I go back
- Our virtual list jumps around when rows have images. Fix the measuring
Use it when
- A long list, feed or grid scrolls slowly or the DOM node count is huge
- Building an infinite feed, chat log, log viewer or masonry grid
- A virtual list flashes blank rows, jumps, or loses scroll position
- Keyboard or screen reader users cannot reach items in a virtual list
Skip it when
- You are building a sortable, filterable table — use data-tables instead
- Rows are slow because components re-render too often — use react-rerenders instead
How to check the result
Record a fast scroll at 4x CPU slowdown: DOM node count stays flat with no long style or layout frames, focus never drops to body, and back navigation restores the same position.
The common mistake it prevents
Virtualizing a list whose real problem is expensive row renders, then losing find-in-page and accessibility for no measurable gain.
Virtualization trades browser features (find-in-page, native scroll anchoring, simple accessibility) for rendering speed. Done well means: measurement proved DOM size was the bottleneck, the lightest technique that fixes it was used, scrolling shows no blank flashes or jumps, the DOM stays bounded regardless of item count, every item stays reachable by keyboard and screen reader, and back navigation returns to the same spot.
Ground rules
- Measure first. A slow list is often slow because each row re-renders or does heavy work, not because there are too many rows. Fix row cost before windowing.
- Prefer, in order: fewer items (pagination, filtering, server search) → CSS
content-visibility: auto→ windowing. Each step costs more browser behavior. - Keys are stable item ids, never the index.
- The scroll container has a definite height (or the list uses the window as scroller).
A virtual list inside a parent with
height: autorenders every item or none. - Tables with sorting, filtering and column logic belong to the data-tables skill; this skill covers the list, feed and grid mechanics.
Workflow
- [ ] 1. Measure: DOM node count, scroll frame times, per-row render cost
- [ ] 2. Reduce row cost (memo, cheaper markup, deferred images) and re-measure
- [ ] 3. Pick the technique (decision table)
- [ ] 4. Implement with stable keys, correct size strategy, overscan
- [ ] 5. Add infinite loading / sticky headers / restoration as needed
- [ ] 6. Accessibility: semantics, focus retention, search instead of find-in-page
- [ ] 7. Verify with a Performance recording on a throttled CPU
1. Measure
document.querySelectorAll("*").length // total DOM nodes
document.querySelector("#list").querySelectorAll("*").length
- Chrome Performance panel, CPU 4× slowdown, record a fast scroll: look for long frames, “Recalculate style” and “Layout” dominating, and scripting in scroll handlers.
- Performance monitor (DevTools > More tools) shows live DOM node count and layouts/sec.
- React: the Profiler shows whether rows re-render on every scroll or parent update.
- Lighthouse’s DOM-size audit has historically warned around 800 body nodes and errored around 1,400; treat these as signals, and decide on measured style/layout cost.
3. Decision table
| Situation | Technique |
|---|---|
| Users rarely go past the first screens; server can filter | Pagination or “Load more” plus search |
| Up to a few thousand moderately complex items, find-in-page and SEO matter | content-visibility: auto |
| Thousands to millions of items, or rows are expensive | Windowing (virtual list) |
| Chat or log that grows at the bottom and prepends history | react-virtuoso (followOutput, firstItemIndex) |
| Masonry or multi-column cards | TanStack Virtual with lanes |
| Two-dimensional grid (spreadsheet) | Row and column virtualizers, or react-window Grid |
Library default: TanStack Virtual (@tanstack/react-virtual, v3; adapters for Vue,
Svelte, Solid, Angular, Lit) — headless, handles fixed and measured sizes, window
scrolling, lanes. Use react-window 2 (List, Grid) for a minimal React API when sizes
are known; react-virtuoso when you want automatic measurement, grouped sticky headers and
reverse (chat) lists with little code. Angular CDK cdk-virtual-scroll-viewport is the
native choice in Angular apps with fixed item size.
CSS content-visibility (lightest option)
.feed-item {
content-visibility: auto; /* skip rendering work while offscreen */
contain-intrinsic-size: auto 320px; /* placeholder height; "auto" remembers the last rendered size */
}
- Offscreen items stay in the DOM and accessibility tree; find-in-page and tab order still work. It skips style, layout and paint, not DOM memory or JS work.
- Set the placeholder close to the real height or the scrollbar jumps as items render.
- Baseline since September 2024; in older browsers the declaration is ignored, which is a safe fallback.
- Do not apply it to items inside a virtual list (pointless) or to above-the-fold content.
TanStack Virtual
Fixed size
import { useVirtualizer } from "@tanstack/react-virtual";
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 56,
overscan: 8,
getItemKey: (i) => items[i].id,
});
<div ref={parentRef} style={{ height: "70vh", overflowY: "auto" }}>
<ul role="list" style={{ height: virtualizer.getTotalSize(), position: "relative", margin: 0 }}>
{virtualizer.getVirtualItems().map((v) => (
<li key={v.key} role="listitem" aria-setsize={items.length} aria-posinset={v.index + 1}
style={{ position: "absolute", top: 0, left: 0, width: "100%", height: v.size,
transform: `translateY(${v.start}px)` }}>
<Row item={items[v.index]} />
</li>
))}
</ul>
</div>
Measured (variable) size
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 120, // estimate on the large side of typical rows
overscan: 6,
getItemKey: (i) => items[i].id,
});
{virtualizer.getVirtualItems().map((v) => (
<li key={v.key} data-index={v.index} ref={virtualizer.measureElement}
style={{ position: "absolute", top: 0, left: 0, width: "100%", transform: `translateY(${v.start}px)` }}>
<Row item={items[v.index]} />
</li>
))}
data-indexis required formeasureElement. Do not set a fixed height on measured items.- Margins are not part of the measured box; use padding inside the item or the
gapoption. - Images and embeds change height after load: give them explicit
width/heightoraspect-ratioso the first measurement is final.
Window scrolling and offsets
import { useWindowVirtualizer } from "@tanstack/react-virtual";
const listRef = useRef<HTMLDivElement>(null);
const virtualizer = useWindowVirtualizer({
count: items.length,
estimateSize: () => 96,
scrollMargin: listRef.current?.offsetTop ?? 0, // content above the list
});
// item transform: translateY(${v.start - virtualizer.options.scrollMargin}px)
Sticky group headers
Keep the active header mounted with a custom rangeExtractor:
import { defaultRangeExtractor, type Range } from "@tanstack/react-virtual";
const activeStickyIndex = useRef(0);
rangeExtractor: useCallback((range: Range) => {
activeStickyIndex.current = [...stickyIndexes].reverse().find((i) => range.startIndex >= i) ?? 0;
const next = new Set([activeStickyIndex.current, ...defaultRangeExtractor(range)]);
return [...next].sort((a, b) => a - b);
}, [stickyIndexes]),
Render the active header with position: sticky; top: 0 and the others absolutely
positioned. The same rangeExtractor technique keeps a focused row mounted (below).
react-window 2 (known sizes)
import { List, type RowComponentProps } from "react-window";
function Row({ index, style, ariaAttributes, items }: RowComponentProps<{ items: Item[] }>) {
return <div style={style} {...ariaAttributes}>{items[index].name}</div>;
}
<List rowComponent={Row} rowCount={items.length} rowHeight={48} rowProps={{ items }}
overscanCount={5} style={{ height: 600 }} />
- v2 is a different API from v1 (
FixedSizeListandVariableSizeListare gone).rowPropspasses data;ariaAttributessuppliesrole="listitem",aria-posinsetandaria-setsize. Variable heights:rowHeight={(index, props) => n}or theuseDynamicRowHeight({ defaultRowHeight })hook (2.2+). - Imperative scrolling:
listRef.current?.scrollToRow({ index, align: "smart" })viauseListRef.
Infinite loading
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery(/* cursor API */);
const rows = data?.pages.flatMap((p) => p.items) ?? [];
const virtualizer = useVirtualizer({ count: hasNextPage ? rows.length + 1 : rows.length, /* ... */ });
const last = virtualizer.getVirtualItems().at(-1);
useEffect(() => {
if (last && last.index >= rows.length - 5 && hasNextPage && !isFetchingNextPage) fetchNextPage();
}, [last?.index, rows.length, hasNextPage, isFetchingNextPage]);
- The extra index renders a loader row; it doubles as a visible “Load more” button for keyboard and screen reader users (and as the fallback if the fetch fails).
- Use cursor pagination so inserts upstream do not duplicate or skip items.
- Cap the in-memory window for unbounded feeds (drop pages far behind) only if memory is measured to be a problem; dropping pages breaks scroll restoration.
- Prepending (chat history) shifts every offset; react-virtuoso’s
firstItemIndexhandles this. With TanStack, adjust the scroll offset by the height added above.
Scroll restoration
Browsers cannot restore a scroll position inside content that does not exist yet.
- TanStack: on leave, save
virtualizer.scrollOffsetandvirtualizer.measurementsCache(sessionStorage keyed by route + query); on return, passinitialOffsetandinitialMeasurementsCache. Keep the data cached (TanStack Query) socountis correct on the first render. - react-virtuoso:
ref.current.getState(cb)on leave,restoreStateFromon mount. - react-window:
defaultScrollOffsetwith a saved offset. - Restore only on back/forward navigation, not on fresh visits.
Accessibility
- List semantics:
role="list"/role="listitem"witharia-setsize(total, or-1if unknown) andaria-posinset, because most items are not in the DOM. - Infinite feeds of articles: the APG feed pattern —
role="feed", each item an<article>witharia-posinset/aria-setsize,aria-busy="true"while loading and back tofalseafter. - Tables:
aria-rowcountandaria-rowindex; see the data-tables skill. - Focus: if the focused item scrolls out of range and unmounts, focus falls to
<body>. Keep the focused index in the rendered range (rangeExtractor) or move focus withscrollToIndexthen focus the element after it mounts. - Keyboard: Tab must not walk thousands of items. Use one tab stop with arrow-key roving focus for selectable lists, or ensure focusable content per row is minimal.
- Find-in-page (Ctrl/Cmd+F) and print cannot see unrendered items. Provide a search box
for large virtual lists; if find-in-page is essential, use
content-visibilityinstead. - SSR: render the first window (or first page) on the server so content is indexable and visible before hydration.
Gotchas
- Blank flashes during fast scroll: increase
overscanmodestly, make rows cheaper, and check for synchronous work in scroll handlers. Huge overscan defeats virtualization. - Jumping scrollbar with measured items: the estimate is far from reality; improve
estimateSizeper item type (estimateSize: (i) => items[i].hasImage ? 320 : 96). - Rows positioned with
topinstead oftransformtrigger layout per frame; usetransform. - A memoized row that receives a new inline object or callback each render re-renders anyway; pass stable props.
- Nested scroll containers (virtual list inside a scrolling page section) break scroll chaining and keyboard paging; prefer the window virtualizer for page-length lists.
scrollToIndexwith unmeasured dynamic items lands approximately; call it again after measurement or align with"start".- Browsers cap element height (on the order of millions of pixels); for tens of millions of rows, page the data rather than relying on one giant scroll height.
Verification
- Performance recording of a fast scroll at 4× CPU slowdown: no long frames from style/layout, DOM node count flat as you scroll.
- Scroll to the end and back: no blank rows, no jumps, item count in DOM stays roughly viewport plus overscan.
- Keyboard: Tab into the list, arrow or Tab through items past the rendered window;
focus never drops to
<body>. - Screen reader announces “item N of M” (or the feed equivalent).
- Open an item, go back: same scroll position and same items visible.
- Infinite loading: the loader row triggers once per page, and the “Load more” fallback works when the fetch fails.
Report format
**Before:** 12,400 DOM nodes, 38 ms average scroll frame (4x CPU), rows re-render on scroll
**Technique:** TanStack Virtual, measured rows, overscan 6, window scroller
**After:** ~600 DOM nodes constant, no frames over 16 ms in recording
**Accessibility:** role=list with aria-setsize/posinset, focused row kept mounted, search box
**Restoration:** offset + measurements cache in sessionStorage on back navigation About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.