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
Escada de segurança de permissões no Claude Code
Amplie de read-only para edições limitadas, comandos de prova e deploy checks sem perder controle.
Claude Code Small PR Proof Pack: pequenas mudanças fáceis de revisar
Um pacote de prova para PRs do Claude Code: diff, checks, URL pública, CTA e rollback.
Gate de revisão antes do commit com Claude Code
Revisão antes do commit com Claude Code: diff, build, URL pública, Gumroad, consultoria, testes e arquivos fora do escopo.