React Virtual Scrolling with Claude Code and TanStack Virtual
Build React virtual scrolling with Claude Code, TanStack Virtual, variable rows, accessibility, and Playwright checks.
When Virtual Scrolling Is the Right Tool
Virtual scrolling renders only the rows that are visible in the scroll viewport plus a small buffer above and below it. It is also called windowing. The browser still behaves as if the full list has height, but React does not mount every row at once. That matters when a screen holds thousands of log lines, customers, chat messages, search results, or admin table rows.
Without virtualization, a simple items.map(...) can create tens of thousands of DOM nodes. The cost is not only the initial render. The browser must keep those nodes in layout calculation, paint, hit testing, event handling, and the accessibility tree. A list can feel fine with 200 rows and then become fragile at 10,000 rows, especially on a laptop running other tools or on a mobile device.
Claude Code helps because the hard part is not typing the hook. The hard part is giving the implementation enough product context: row height, keyboard behavior, scroll restoration, loading states, search filters, mobile width, screen reader expectations, and the tests that prove the feature is safe. If you ask only for “virtual scrolling”, you will usually get a demo. If you ask for a log viewer, customer list, chat history, search result page, or admin table with concrete constraints, you can get a component that fits a real codebase.
Use virtual scrolling for large log viewers, CRM customer lists, chat timelines, search results, audit trails, product catalogs, and admin data tables. Avoid it for tiny lists, SEO-critical static article lists, or screens where explicit page numbers are the primary navigation model. If the user must load more records from the server as they move, combine this topic with infinite scroll. For broader rendering work, pair it with the Claude Code performance optimization guide.
Give Claude Code a Complete Brief
Virtual scrolling is easy to make plausible and easy to make subtly wrong. A useful Claude Code prompt names the library, the data shape, the accessibility rules, the layout budget, and the verification steps. That makes the review concrete instead of subjective.
Implement a React 18 + TypeScript virtualized log viewer.
Requirements:
- Use @tanstack/react-virtual.
- Support more than 10,000 rows without mounting every row.
- Treat the default row height as 44px.
- Add role, aria-label, aria-posinset, and aria-setsize where appropriate.
- Keep the layout usable at 390px viewport width with no horizontal page overflow.
- Explain the overscan choice in the article text, not as noisy comments.
- Add a Playwright check for scroll behavior and mobile width.
- Review the final code against the official TanStack Virtual docs.
This prompt protects the feature surface. Claude Code now has to think about the user who scrolls quickly, the user who uses a keyboard, the user who opens a detail page and comes back, and the reviewer who needs evidence. The same structure works for a customer list, a chat history screen, a search result page, or a back-office table. Change the row content and acceptance criteria, but keep the proof-oriented brief.
Fixed-Height Log Viewer with TanStack Virtual
For new React work, @tanstack/react-virtual is a practical default. TanStack Virtual is a headless utility: it calculates virtual items, offsets, and total size, while you keep control over markup and styling. Check the official TanStack Virtual Introduction and the Virtualizer API before adapting the code to your project.
Install the package first:
npm install @tanstack/react-virtual
The simplest production-friendly starting point is a fixed-height log viewer. The row height is predictable, so estimateSize can return a constant. The outer element scrolls. The inner element creates the total scroll height. The actual rows are absolutely positioned at the virtual offsets.
import { useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
type LogRow = {
id: string;
level: "info" | "warn" | "error";
message: string;
createdAt: string;
};
export function VirtualLogViewer({ rows }: { rows: LogRow[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 44,
overscan: 12,
getItemKey: (index) => rows[index]?.id ?? index,
});
return (
<section aria-labelledby="log-heading">
<h2 id="log-heading">Application logs</h2>
<div
ref={parentRef}
data-testid="virtual-log-viewport"
role="list"
aria-label={`Application logs, ${rows.length} rows`}
style={{
height: 520,
overflow: "auto",
border: "1px solid #d4d4d8",
borderRadius: 6,
}}
>
<div
style={{
height: rowVirtualizer.getTotalSize(),
position: "relative",
width: "100%",
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const row = rows[virtualRow.index];
if (!row) return null;
return (
<div
key={virtualRow.key}
role="listitem"
aria-posinset={virtualRow.index + 1}
aria-setsize={rows.length}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
display: "grid",
gridTemplateColumns: "92px 72px minmax(0, 1fr)",
gap: 12,
alignItems: "center",
padding: "0 12px",
boxSizing: "border-box",
borderBottom: "1px solid #eee",
}}
>
<time dateTime={row.createdAt}>{row.createdAt}</time>
<strong>{row.level.toUpperCase()}</strong>
<span style={{ overflowWrap: "anywhere" }}>{row.message}</span>
</div>
);
})}
</div>
</div>
</section>
);
}
The overscan value controls how many extra items are rendered just outside the visible range. Too little overscan can show blank gaps while fast scrolling. Too much overscan makes virtualization less useful because React mounts many hidden rows anyway. For lightweight log rows, 8 to 16 is a reasonable range to test. For complex rows with charts, avatars, menus, or syntax highlighting, start lower and measure before increasing it.
Variable-Height Chat History
Fixed height is the easy case. Chat histories, support conversations, audit comments, and AI response logs usually have variable row height. Message length, images, attachments, translated text, and error banners all change the final size. In those screens, use a good estimate and then measure the rendered element.
import { useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
type Message = {
id: string;
author: string;
body: string;
avatarUrl?: string;
};
export function VirtualChatHistory({ messages }: { messages: Message[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: messages.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 96,
overscan: 8,
getItemKey: (index) => messages[index]?.id ?? index,
});
return (
<div
ref={parentRef}
role="log"
aria-label="Chat history"
style={{ height: 520, overflow: "auto" }}
>
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
{virtualizer.getVirtualItems().map((virtualItem) => {
const message = messages[virtualItem.index];
if (!message) return null;
return (
<article
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualItem.start}px)`,
padding: "12px 16px",
boxSizing: "border-box",
}}
>
{message.avatarUrl ? (
<img
src={message.avatarUrl}
alt=""
width={32}
height={32}
loading="lazy"
onLoad={() => virtualizer.measure()}
/>
) : null}
<p style={{ margin: 0, fontWeight: 700 }}>{message.author}</p>
<p style={{ margin: "4px 0 0", overflowWrap: "anywhere" }}>
{message.body}
</p>
</article>
);
})}
</div>
</div>
);
}
The data-index and measureElement pairing matters because the virtualizer needs to connect the rendered node to the item index. Images are a common source of height drift. Reserve image dimensions, lazy-load intentionally, and trigger measurement when an image finishes loading. In a chat product, also decide whether new messages should keep the viewport pinned to the latest message or respect the reader’s current scroll position.
Accessibility and Keyboard Behavior
Virtualized lists are unusual for assistive technology because not every row exists in the DOM at the same time. You should still expose the list purpose, total count, current position, and keyboard movement. A customer list may use arrow keys to move the active row and Enter to open a profile. A search result list may let users jump between results without touching the mouse. An admin table may need selection state that survives scrolling.
import type { KeyboardEvent } from "react";
type KeyboardParams = {
activeIndex: number;
rowCount: number;
setActiveIndex: (index: number) => void;
scrollToIndex: (index: number) => void;
};
export function handleVirtualListKeyDown(
event: KeyboardEvent,
{ activeIndex, rowCount, setActiveIndex, scrollToIndex }: KeyboardParams,
) {
const lastIndex = Math.max(0, rowCount - 1);
let nextIndex = activeIndex;
if (event.key === "ArrowDown") nextIndex = Math.min(lastIndex, activeIndex + 1);
if (event.key === "ArrowUp") nextIndex = Math.max(0, activeIndex - 1);
if (event.key === "PageDown") nextIndex = Math.min(lastIndex, activeIndex + 10);
if (event.key === "PageUp") nextIndex = Math.max(0, activeIndex - 10);
if (event.key === "Home") nextIndex = 0;
if (event.key === "End") nextIndex = lastIndex;
if (nextIndex !== activeIndex) {
event.preventDefault();
setActiveIndex(nextIndex);
scrollToIndex(nextIndex);
}
}
For focus, avoid relying only on row DOM nodes if rows can unmount during scrolling. A stable pattern is to keep focus on the scroll container and expose the active row with aria-activedescendant. Test with keyboard, screen reader, and high zoom separately. For deeper UI review, connect this implementation with the Claude Code accessibility guide.
Scroll Restoration and SSR Risk
A virtualized customer list often links to a detail page. When the user returns, resetting to the first row is a productivity bug. Store the scroll offset with a key that includes the current filters and sort order. If the query changes, do not reuse an old offset blindly because it may point to a different customer.
import { useEffect } from "react";
import type { Virtualizer } from "@tanstack/react-virtual";
type RestoreOptions = {
storageKey: string;
getScrollElement: () => HTMLElement | null;
virtualizer: Virtualizer<HTMLElement, Element>;
};
export function useVirtualScrollRestoration({
storageKey,
getScrollElement,
virtualizer,
}: RestoreOptions) {
useEffect(() => {
const savedOffset = sessionStorage.getItem(storageKey);
if (!savedOffset) return;
requestAnimationFrame(() => {
virtualizer.scrollToOffset(Number(savedOffset));
});
}, [storageKey, virtualizer]);
useEffect(() => {
const element = getScrollElement();
if (!element) return;
const save = () => {
sessionStorage.setItem(storageKey, String(element.scrollTop));
};
element.addEventListener("scroll", save, { passive: true });
return () => {
save();
element.removeEventListener("scroll", save);
};
}, [getScrollElement, storageKey]);
}
Server rendering adds another trap. The server cannot measure the real scroll container or dynamic row height. If the first client render produces a very different height, users may see a jump after hydration. Keep the virtualized surface client-side when necessary, give the container a stable height, reserve media dimensions, and tune estimateSize from real data rather than a guess.
Playwright Checks for Scroll and Width
Virtual scrolling needs proof. A local demo can appear smooth while mobile width overflows, a row cannot be reached after scrolling, or a console error appears only after the virtualizer measures dynamic content. Add one focused browser test before treating the feature as done.
import { expect, test } from "@playwright/test";
test("virtual log viewer scrolls without horizontal overflow", async ({ page }) => {
const errors: string[] = [];
page.on("console", (message) => {
if (message.type() === "error") errors.push(message.text());
});
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/debug/virtual-log-viewer");
const viewport = page.getByTestId("virtual-log-viewport");
await expect(viewport).toBeVisible();
const before = await viewport.boundingBox();
await viewport.evaluate((node) => {
node.scrollTop = 2400;
});
await expect(page.getByText("Log #250")).toBeVisible();
const after = await viewport.boundingBox();
expect(after?.width).toBe(before?.width);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(
await page.evaluate(() => document.documentElement.clientWidth),
);
expect(errors).toEqual([]);
});
Use deterministic test data. A log viewer can seed Log #250. A customer list can seed a known customer ID. A chat history can seed a message ID near the middle. Width checks are especially important because absolute positioning and long strings can create page-level horizontal overflow even when the virtual viewport itself looks correct.
Concept Model and Claude Review Prompt
A compact mental model helps both the implementer and reviewer:
scrollTop
-> visible index range
-> overscan range
-> virtual rows only
-> translateY to each row's real position
-> measureElement to correct real height
After implementation, ask Claude Code to review failure modes rather than give a vague approval.
Review this React virtual scrolling implementation.
Check:
- Does it follow the official TanStack Virtual API?
- Are fixed-height and dynamic-height responsibilities separated?
- Is overscan too small, causing blank gaps during fast scrolling?
- Are role, aria attributes, and keyboard behavior coherent?
- Can image loading change row height without remeasurement?
- Is scroll restoration handled when returning from a detail page?
- Can SSR or hydration change initial row height?
- Does Playwright verify mobile width and a scrolled-to row?
This prompt is intentionally critical. It points Claude Code at the defects that tend to survive visual review: dynamic height drift, focus loss, screen reader ambiguity, scroll restoration, overscan tuning, image loading, and SSR differences.
Common Pitfalls
| Pitfall | Result | Fix |
|---|---|---|
| Treating dynamic rows as fixed rows | Rows overlap or jump while scrolling | Use measureElement and reserve media dimensions |
| Too little overscan | Blank gaps during fast scrolling | Tune 8 to 16 for light rows and measure heavier rows |
| Too much overscan | React mounts too many hidden rows | Check DOM count and React Profiler results |
| Missing keyboard support | Mouse-free users cannot navigate rows | Keep focus stable and implement arrow, page, home, and end keys |
| Weak screen reader context | Users do not know position or total count | Use clear labels plus aria-posinset and aria-setsize |
| No scroll restoration | Returning from detail view resets work context | Store scrollTop with filter and sort keys |
| Image load height drift | Chat or product cards jump after images load | Set image dimensions and remeasure on load |
| SSR mismatch | The page shifts after hydration | Use stable container height and client-side measurement |
The business risk is not only a slower page. In a SaaS admin screen, the user may lose their selected customer. In a support tool, an agent may lose the message they were answering. In a content site, a search result page may become hard to scan and weaken the path to templates, products, or consultation.
ClaudeCodeLab CTA and Related Reading
Treat virtual scrolling as a product behavior, not a trick. The implementation decision changes depending on whether you are building a log viewer, a customer table, a chat timeline, a search page, or an admin table. Ask Claude Code to implement the smallest useful component, then ask it to review the exact failure modes above.
For official references, start with the TanStack Virtual docs and Virtualizer API. For adjacent ClaudeCodeLab articles, read infinite scroll, accessibility implementation, and performance optimization.
If your team wants this pattern applied to a real repository, the Claude Code training and consultation page is the natural next step. A useful session can cover row design, prompts, CLAUDE.md rules, accessibility review, and Playwright proof using the screens your team actually maintains.
What Happened When I Tried This
In a fixed-height log viewer, switching from a full rows.map render to TanStack Virtual reduced mounted DOM rows dramatically and made scroll issues easier to inspect. The variable-height chat example needed more care. Without image dimensions and remeasurement on image load, the scroll offset moved after media appeared. The most useful publish checklist became simple: tune estimateSize with real data, reserve image space, test 390px width, scroll to a known middle row, and ask Claude Code to review the implementation against the official API and the failure list above.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Permission Safety Ladder: Expand Access Without Losing Control
A beginner-friendly ladder for moving Claude Code from read-only to limited edits, proof commands, and deploy checks.
Claude Code Small PR Proof Pack: Make Tiny Changes Reviewable
A practical proof pack for Claude Code PRs: diff, checks, public URL, CTA path, and rollback note.
Claude Code Review Gate Before Commit: Diff, Tests, Public URL, and CTA Checks
A commit-time review gate for Claude Code work: diff scope, build, public URL, revenue CTA links, missing tests, and unrelated files.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.