Scroll virtual en React con Claude Code y TanStack Virtual
Implementa scroll virtual con Claude Code: TanStack Virtual, filas variables, accesibilidad y Playwright.
Cuándo usar scroll virtual
El scroll virtual renderiza solo las filas visibles y unas pocas filas adicionales alrededor del viewport. La lista puede tener diez mil elementos, pero React no monta diez mil nodos DOM a la vez. DOM significa la estructura de elementos que el navegador mantiene para dibujar, calcular layout, gestionar eventos y exponer información a tecnologías de asistencia. Cuando la lista crece, el coste aparece en el render inicial, en el scroll, en la memoria y en la accesibilidad.
Claude Code puede generar el componente rápido, pero una petición como “crea scroll virtual” suele producir una demo incompleta. En producción hay que decidir si las filas tienen altura fija o variable, qué ocurre con imágenes cargadas tarde, cómo se conserva la posición al volver desde una pantalla de detalle, cómo navega alguien con teclado, qué información recibe un lector de pantalla y cómo se comprueba que el móvil no tiene overflow horizontal.
Los casos más claros son un visor de logs, una lista de clientes, un historial de chat, resultados de búsqueda y tablas de administración. Un visor de logs necesita mostrar miles de líneas sin congelar el navegador. Una lista de clientes en un CRM necesita selección, filtros y retorno desde detalle. Un chat puede tener mensajes largos, avatares y adjuntos. Los resultados de búsqueda se redibujan con cada filtro. Las tablas internas mezclan columnas, permisos, acciones y estados de selección. Si además cargas más datos desde el servidor, revisa también infinite scroll. Para mejoras generales de render, conecta este tema con performance optimization.
Brief inicial para Claude Code
El resultado mejora mucho cuando el prompt incluye restricciones verificables. No pidas solo velocidad; pide comportamiento, accesibilidad y pruebas.
Implementa un visor de logs virtualizado con React 18 + TypeScript.
Requisitos:
- Usar @tanstack/react-virtual.
- Soportar mas de 10000 filas sin montar todas en el DOM.
- Usar 44px como altura base de fila.
- Agregar role, aria-label, aria-posinset y aria-setsize.
- Mantener el layout correcto a 390px de ancho sin overflow horizontal.
- Explicar la eleccion de overscan.
- Agregar una prueba Playwright para scroll y ancho movil.
- Revisar el resultado contra la documentacion oficial de TanStack Virtual.
Este tipo de brief obliga a Claude Code a pensar en la superficie completa. Para una lista de clientes cambia los campos por nombre, plan y estado. Para resultados de búsqueda usa título, resumen y etiquetas. Para un chat usa autor, cuerpo y adjuntos. Lo importante es que el componente nazca con criterios de revisión, no como un fragmento aislado.
Lista fija con TanStack Virtual
En React, @tanstack/react-virtual es una opción práctica. Es una utilidad headless: calcula elementos virtuales, offsets y tamaño total, pero no impone marcado ni estilos. Consulta la referencia oficial de TanStack Virtual y la Virtualizer API antes de adaptarlo.
npm install @tanstack/react-virtual
Este ejemplo implementa un visor de logs con filas de altura fija. El contenedor externo hace scroll, el interno representa la altura total y cada fila visible se coloca contranslateY.
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 cuántas filas se renderizan fuera del viewport. Poco overscan puede mostrar huecos blancos durante scroll rápido. Demasiado overscan reduce el beneficio porque React vuelve a montar muchas filas invisibles. En logs de texto prueba entre 8 y 16. En filas con avatares, menús, gráficos o resaltado de código, empieza con menos y mide.
Filas variables para chat
Los historiales de chat, comentarios de soporte y respuestas de IA rara vez tienen altura fija. Cambian por la longitud del mensaje, imágenes, adjuntos, traducciones y avisos de error. En ese caso usa una estimación y mide el elemento real.
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>
);
}
La imagen tardía es una trampa común: cuando carga, cambia la altura real y el scroll salta. Reserva dimensiones, usa placeholders si hace falta y vuelve a medir al cargar. En un chat también decide si los mensajes nuevos deben fijar la vista al final o respetar la posición del usuario cuando está leyendo mensajes antiguos.
Accesibilidad y teclado
Como no todas las filas existen en el DOM al mismo tiempo, la lista debe comunicar bien su propósito, total, posición y navegación. Una lista de clientes debería permitir flechas, PageUp, PageDown, Home y 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);
}
}
No confíes únicamente en el foco de la fila si esa fila puede desmontarse. Un patrón estable es dejar el foco en el contenedor y anunciar la fila activa conaria-activedescendant. Revisa teclado, lector de pantalla y zoom alto junto con la guía de accesibilidad.
Prueba Playwright y revisión crítica
La verificación mínima debe cubrir ancho móvil, scroll a una fila conocida, overflow horizontal y errores de consola.
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([]);
});
Después, pide a Claude Code una revisión con fallos concretos:
Revisa esta implementacion de virtual scroll en React.
Comprueba:
- Si sigue la API oficial de TanStack Virtual.
- Si separa filas fijas y filas de altura variable.
- Si overscan puede generar huecos durante scroll rapido.
- Si role, aria y teclado son coherentes.
- Si las imagenes recalculan altura al cargar.
- Si se restaura la posicion al volver desde detalle.
- Si SSR o hydration pueden cambiar la altura inicial.
- Si Playwright valida ancho movil y una fila despues de hacer scroll.
Trampas, CTA y resultado real
Las trampas principales son: tratar filas variables como fijas, usar poco overscan, usar demasiado overscan, olvidar teclado, no dar contexto al lector de pantalla, no restaurar posición, no medir imágenes después de cargar, sufrir diferencias de SSR y romper el ancho con cadenas largas. El modelo mental útil esscrollTop -> rango visible -> overscan -> filas virtuales -> translateY -> medición real.
Si necesitas aplicar esto a un repositorio real, la página de formación y consultoría de Claude Code puede ayudarte a convertir la implementación en reglas de equipo: prompt inicial, CLAUDE.md, revisión de accesibilidad y prueba Playwright. Referencias relacionadas: TanStack Virtual docs, infinite scroll y performance optimization.
Al probar el flujo, el visor de logs fijo redujo mucho los nodos montados frente a unrows.map completo. En el chat de altura variable, el problema real apareció con imágenes: sin dimensiones reservadas y re-medición, la posición saltaba ligeramente. La lista de verificación más útil fue ajustarestimateSize con datos reales, probar 390px de ancho, desplazarse a una fila media conocida y confirmar que no había overflow horizontal ni errores de consola.
PDF gratis: cheatsheet de Claude Code
Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.
Cuidamos tus datos y no enviamos spam.
Sobre el autor
Masa
Ingeniero enfocado en workflows prácticos con Claude Code.
Artículos relacionados
Escalera de permisos de Claude Code para ampliar acceso sin perder control
Pasa de read-only a ediciones limitadas, comandos de prueba y checks de deploy con menos riesgo.
Claude Code Small PR Proof Pack: cambios pequeños que sí se pueden revisar
Un paquete de prueba para PRs de Claude Code: diff, checks, URL pública, CTA y rollback.
Gate de revisión antes del commit con Claude Code
Cómo revisar con Claude Code antes del commit: diff, build, URL pública, Gumroad, consultoría, tests y archivos ajenos.