Scroll virtual em React com Claude Code e TanStack Virtual
Implemente scroll virtual com Claude Code, TanStack Virtual, altura variável, acessibilidade e Playwright.
Quando usar scroll virtual
Scroll virtual renderiza apenas as linhas visíveis no viewport e algumas linhas extras antes e depois. A lista pode ter dez mil registros, mas o React não precisa montar dez mil nós DOM de uma vez. DOM é a árvore de elementos que o navegador usa para layout, pintura, eventos e tecnologias assistivas. Quando muitas linhas invisíveis continuam montadas, o custo aparece em memória, scroll, foco e tempo de renderização.
Claude Code ajuda a escrever o componente, mas pedir apenas “faça virtual scroll” costuma gerar uma demo. Em produção, você precisa informar se a linha tem altura fixa ou variável, como lidar com imagem carregada depois, como restaurar a posição ao voltar de uma tela de detalhe, como navegar por teclado, como avisar leitores de tela e como provar que o layout não quebra em 390px de largura.
Os casos mais úteis são visor de logs, lista de clientes, histórico de chat, resultados de busca e tabelas administrativas. Logs precisam mostrar milhares de linhas sem travar. Listas de clientes em CRM precisam manter seleção, filtros e retorno para a posição anterior. Chats misturam mensagens longas, avatares e anexos. Busca redesenha resultados após filtros. Tabelas internas juntam colunas, permissões, ações e estado de seleção. Se o desafio também inclui carregar mais dados do servidor, leia infinite scroll. Para otimização geral, veja performance optimization.
Brief para o Claude Code
Um bom prompt transforma a tarefa em algo revisável. Ele deve incluir biblioteca, escala, acessibilidade, largura mobile e teste.
Implemente um visor de logs virtualizado com React 18 + TypeScript.
Requisitos:
- Usar @tanstack/react-virtual.
- Suportar mais de 10000 linhas sem montar todas no DOM.
- Usar 44px como altura base da linha.
- Adicionar role, aria-label, aria-posinset e aria-setsize.
- Manter o layout correto em 390px sem overflow horizontal da pagina.
- Explicar a escolha de overscan.
- Adicionar teste Playwright para scroll e largura mobile.
- Revisar o resultado com a documentacao oficial do TanStack Virtual.
Esse formato evita que o modelo entregue apenas um exemplo bonito. Para lista de clientes, troque os campos por nome, plano, status e última atividade. Para resultados de busca, use título, resumo e tags. Para chat, use autor, corpo e anexos. O padrão é sempre o mesmo: primeiro defina o risco, depois peça a implementação.
Lista fixa com TanStack Virtual
Em React,@tanstack/react-virtual é uma escolha prática. A biblioteca é headless: ela calcula itens virtuais, offsets e tamanho total, mas não impõe HTML nem CSS. Você controla marcação, layout e acessibilidade. As referências oficiais são TanStack Virtual docs e Virtualizer API.
npm install @tanstack/react-virtual
O exemplo abaixo cria um visor de logs com altura fixa. O elemento externo rola, o interno representa a altura total e cada linha visível é posicionada comtranslateY.
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 define quantas linhas extras são renderizadas fora do viewport. Pouco overscan pode mostrar espaços em branco durante scroll rápido. Muito overscan monta linhas demais e reduz o ganho. Para logs simples, teste entre 8 e 16. Para linhas com avatar, menu, gráfico ou destaque de código, comece menor e meça.
Altura variável em histórico de chat
Chats, comentários de suporte e respostas de IA raramente têm altura fixa. Texto, imagem, anexo, tradução e mensagem de erro mudam o tamanho real da linha. Nesse cenário, use uma estimativa e meça o elemento renderizado.
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>
);
}
Imagem que carrega depois é uma armadilha comum. Reserve largura e altura, e remeça quando carregar. Em chat, defina também se novas mensagens devem prender a tela no final ou respeitar a posição de quem está lendo histórico antigo.
Acessibilidade e teclado
Como nem todas as linhas existem no DOM, a lista precisa comunicar melhor seu propósito, total, posição atual e atalhos. Uma lista de clientes deve funcionar com setas, PageUp, PageDown, Home e End.
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);
}
}
Evite depender do foco direto na linha, porque ela pode desmontar durante o scroll. Um padrão mais estável é manter foco no contêiner e indicar a linha ativa comaria-activedescendant. Ao voltar de uma tela de detalhe, restaurescrollTop com uma chave que inclua filtros e ordenação. Para revisar isso com mais profundidade, veja accessibility guide.
Teste Playwright e revisão
A prova mínima deve cobrir largura mobile, scroll até uma linha conhecida, ausência de overflow horizontal e erros de console.
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([]);
});
Depois, peça uma revisão crítica ao Claude Code.
Revise esta implementacao de scroll virtual em React.
Verifique:
- Se segue a API oficial do TanStack Virtual.
- Se separa altura fixa e altura variavel.
- Se overscan baixo pode criar espacos em branco.
- Se role, aria e teclado estao coerentes.
- Se imagens recalculam altura ao carregar.
- Se a posicao e restaurada ao voltar de detalhes.
- Se SSR ou hydration podem alterar a altura inicial.
- Se Playwright valida largura mobile e linha apos scroll.
Armadilhas, CTA e resultado
As armadilhas principais são tratar altura variável como fixa, usar overscan pequeno demais, usar overscan grande demais, esquecer teclado, não informar total e posição para leitor de tela, não restaurar scroll, não medir imagem após carregar, ignorar diferença de SSR e deixar texto longo quebrar a largura mobile. O modelo mental éscrollTop -> faixa visível -> overscan -> linhas virtuais -> translateY -> medição real.
Se sua equipe quer aplicar isso em um repositório real, a página de treinamento e consultoria Claude Code pode ajudar a montar requisitos, prompts, regrasCLAUDE.md, revisão de acessibilidade e evidência Playwright. Referências úteis: TanStack Virtual docs, infinite scroll e performance optimization.
Ao testar, o visor de logs fixo reduziu muito as linhas montadas em comparação comrows.map. No chat de altura variável, o problema apareceu nas imagens: sem espaço reservado e nova medição, o scroll saltava levemente. A checklist mais útil foi ajustarestimateSize com dados reais, testar 390px, rolar até uma linha conhecida no meio e confirmar que não há overflow horizontal.
PDF grátis: cheatsheet do Claude Code
Informe seu e-mail e baixe uma página com comandos, hábitos de revisão e workflows seguros.
Cuidamos dos seus dados e não enviamos spam.
Sobre o autor
Masa
Engenheiro focado em workflows práticos com Claude Code.
Artigos relacionados
Como pedir ao Claude Code para mexer em um único arquivo
Do desastre em que um 'deixa melhor' alterou 40 linhas nasceu um template de prompt que limita o escopo, valida e permite reverter.
Recuperar de negações de permissão no Claude Code sem enfraquecer guardrails
Transforme um comando negado em plano seguro com motivo, alternativa, provas e critérios de nova tentativa.
Claude Code Harness Smoke Test: prova de 15 minutos antes de confiar em um agente
Um smoke test para escopo, áreas bloqueadas, comandos de prova, URL pública e CTAs de receita no Claude Code.