Virtual Scrolling in React mit Claude Code und TanStack Virtual
Baue Virtual Scrolling mit Claude Code, TanStack Virtual, variabler Höhe, Accessibility und Playwright.
Wann Virtual Scrolling sinnvoll ist
Virtual Scrolling rendert nur die Zeilen, die im sichtbaren Scrollbereich liegen, plus einen kleinen Puffer davor und danach. Die Liste kann zehntausend Einträge haben, aber React muss nicht zehntausend DOM-Knoten gleichzeitig mounten. DOM meint die Elementstruktur, mit der der Browser Layout, Paint, Events und Accessibility-Informationen verwaltet. Viele unsichtbare Knoten kosten also trotzdem Zeit.
Claude Code kann den React-Code schnell schreiben, aber ein kurzer Auftrag wie “baue virtual scroll” erzeugt meist nur eine Demo. In einem echten Produkt müssen Zeilenhöhe, Tastaturbedienung, Screenreader-Kontext, Rückkehr von Detailseiten, mobile Breite, Bildladeverhalten, SSR und Tests bedacht werden. Genau diese Punkte entscheiden, ob die Liste nur schnell aussieht oder im Alltag stabil bleibt.
Typische Use Cases sind Log Viewer, Kundenlisten, Chat-Verläufe, Suchergebnisse und Admin-Tabellen. Logs können sehr lang werden und müssen schnell scanbar bleiben. Kundenlisten brauchen Filter, Auswahl und Rückkehr zur alten Position. Chats enthalten lange Texte, Avatare und Anhänge. Suchergebnisse werden nach jedem Filter neu aufgebaut. Admin-Tabellen kombinieren Spalten, Aktionen, Berechtigungen und Auswahlzustände. Wenn zusätzlich neue Daten vom Server nachgeladen werden, lies auch Infinite Scroll. Für breitere Rendering-Themen passt Performance Optimization.
Der richtige Auftrag an Claude Code
Ein guter Prompt beschreibt nicht nur die gewünschte Technik, sondern auch die Abnahmekriterien. Dadurch kann Claude Code die Implementierung und die spätere Review an denselben Risiken ausrichten.
Implementiere einen virtualisierten Log Viewer mit React 18 + TypeScript.
Anforderungen:
- @tanstack/react-virtual verwenden.
- Mehr als 10000 Zeilen unterstützen, ohne alle Zeilen im DOM zu mounten.
- Standard-Zeilenhöhe 44px.
- role, aria-label, aria-posinset und aria-setsize ergänzen.
- Bei 390px Viewport-Breite kein horizontales Page-Overflow erzeugen.
- Die overscan-Entscheidung erklären.
- Playwright-Test für Scroll-Verhalten und mobile Breite hinzufügen.
- Den Code gegen die offizielle TanStack Virtual Dokumentation prüfen.
Für eine Kundenliste ersetzt du die Log-Felder durch Name, Tarif, Status und letzte Aktivität. Für Suchergebnisse nutzt du Titel, Auszug und Tags. Für Chat-Verläufe kommen Autor, Text und Medien dazu. Der wichtige Teil ist nicht der konkrete Datentyp, sondern dass Claude Code von Anfang an die Fehlerfälle kennt.
Feste Zeilenhöhe mit TanStack Virtual
Für React ist@tanstack/react-virtual eine gute Basis. Die Bibliothek ist headless: Sie berechnet virtuelle Elemente, Offsets und Gesamthöhe, liefert aber kein fertiges Styling. Markup, Layout und Accessibility bleiben unter deiner Kontrolle. Die offizielle Grundlage sind die TanStack Virtual docs und die Virtualizer API.
npm install @tanstack/react-virtual
Dieses Beispiel zeigt einen Log Viewer mit fester Höhe. Das äußere Element scrollt, das innere Element erzeugt die Gesamthöhe, und die sichtbaren Zeilen werden pertranslateY positioniert.
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>
);
}
overscan rendert zusätzliche Zeilen außerhalb des sichtbaren Bereichs. Ist der Wert zu klein, entstehen beim schnellen Scrollen weiße Lücken. Ist er zu groß, wachsen DOM und React-Arbeit wieder an. Für einfache Textlogs sind 8 bis 16 ein sinnvoller Testbereich. Für schwere Zeilen mit Avatar, Menü, Diagramm oder Syntax Highlighting beginnt man niedriger und misst.
Variable Höhe für Chat-Verläufe
Chat, Support-Kommentare und AI-Antworten haben selten feste Höhe. Textlänge, Bilder, Anhänge, Übersetzungen und Fehlermeldungen verändern die Größe. Deshalb gibt man eine Schätzung an und misst dann das echte 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>
);
}
Bilder sind ein häufiger Grund für nachträgliche Höhenänderungen. Reserviere Breite und Höhe und miss nach dem Laden erneut. Bei Chats musst du außerdem entscheiden, ob neue Nachrichten den Viewport unten halten oder ob die aktuelle Leseposition respektiert wird.
Accessibility, Tastatur und Rückkehrposition
Eine virtualisierte Liste enthält nicht alle Zeilen im DOM. Deshalb muss sie Zweck, Gesamtanzahl, aktuelle Position und Tastaturverhalten klar ausdrücken. Eine Kundenliste sollte mit Pfeiltasten, PageUp, PageDown, Home und End bedienbar sein.
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);
}
}
Fokus direkt auf eine Zeile zu setzen ist riskant, wenn diese Zeile beim Scrollen unmountet. Stabiler ist Fokus auf dem Container und eine aktive Zeile überaria-activedescendant. Nach einer Detailseite sollte der Scrolloffset wiederhergestellt werden, aber mit einem Schlüssel, der Filter und Sortierung enthält. Vertiefung bietet der Accessibility Guide.
Playwright-Test und Review-Prompt
Ein sichtbarer Demo-Scroll reicht nicht. Prüfe mobile Breite, Scroll zu einer bekannten Zeile, horizontales Overflow und Konsolenfehler.
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([]);
});
Die anschließende Claude-Code-Review sollte konkrete Fehler suchen.
Pruefe diese React-Virtual-Scrolling-Implementierung.
Checkliste:
- Nutzung der offiziellen TanStack Virtual API.
- Klare Trennung von fester und variabler Zeilenhoehe.
- Kein zu kleiner overscan mit weissen Luecken beim schnellen Scrollen.
- Konsistente role-, aria- und Tastatur-Logik.
- Erneute Messung nach Bildladevorgang.
- Wiederherstellung der Scrollposition nach Detailseiten.
- Kein SSR- oder Hydration-Sprung in der Anfangshoehe.
- Playwright prueft mobile Breite und eine gescrollte Zielzeile.
Fallstricke, CTA und Testnotiz
Die häufigsten Fehler sind: variable Zeilen wie feste behandeln, overscan zu klein oder zu groß wählen, Tastaturbedienung vergessen, Screenreadern keine Gesamtzahl geben, Scrollposition verlieren, Bilder nach dem Laden nicht messen, SSR-Höhenunterschiede ignorieren und lange Strings ohne Umbruch in mobile Layouts lassen. Das mentale Modell lautetscrollTop -> sichtbarer Bereich -> overscan -> virtuelle Zeilen -> translateY -> reale Messung.
Wenn dein Team diese Technik in einem echten Admin-Tool, Log Viewer, Suchergebnis oder Chat einsetzen will, kann Claude Code Training und Beratung helfen: Anforderungen, Prompts, CLAUDE.md, Accessibility-Review und Playwright-Nachweis werden am echten Repository aufgebaut. Gute Anschlusslinks sind TanStack Virtual docs, Infinite Scroll und Performance Optimization.
Beim Testen reduzierte der feste Log Viewer die montierten DOM-Zeilen deutlich gegenüber einem vollständigenrows.map. Der variable Chat zeigte dagegen sofort den Bildlade-Fall: Ohne reservierte Bildgröße und erneute Messung sprang die Scrollposition leicht. Die beste Veröffentlichungsprüfung war daher: estimateSize mit echten Daten kalibrieren, 390px Breite testen, zu einer bekannten mittleren Zeile scrollen und horizontales Overflow ausschließen.
Kostenloses PDF: Claude-Code-Cheatsheet
E-Mail eintragen und eine Seite mit Befehlen, Review-Gewohnheiten und sicheren Workflows herunterladen.
Wir schützen Ihre Daten und senden keinen Spam.
Über den Autor
Masa
Engineer für praktische Claude-Code-Workflows und Team-Einführung.
Ähnliche Artikel
Claude Code Permission Safety Ladder: Zugriff kontrolliert erweitern
Von read-only zu begrenzten Änderungen, Prüfbefehlen und Deploy-Checks mit klarer Kontrolle.
Claude Code Small PR Proof Pack: kleine Änderungen reviewbar machen
Ein Proof Pack für Claude-Code-PRs: Diff, Checks, öffentliche URL, CTA-Pfad und Rollback.
Claude-Code-Review-Gate vor dem Commit
Vor dem Commit mit Claude Code prüfen: Diff, Build, öffentliche URL, Gumroad-Links, Beratung-CTA, fehlende Tests und fremde Dateien.