VirtualList
A list of thousands of rows that scrolls like any other, with only the rows in view in the page.
- Scroll through 10,000 runs: only the rows in view, and six beyond each edge, are rendered.
- Failed runs take a second line. Each row is measured as it appears, and the rows in view hold still.
- Tab to the list: End and Home go to the last and first run. Or type a run number and go to it.
Field marks
- Import
import { VirtualList } from "cennge-ui";- Size
- 3.2 KB gzip for this import, its dependencies aside
- Pattern
- A list (
role="list") whose rows carryaria-setsizeandaria-posinset, in a ScrollArea - Keyboard
- HomeEnd
- State
- None to hold:
itemsin, rows out;scrollToIndexthroughlistRef - SSR
- Rendered on the server in the Next.js check; hydrates without a warning
- Virtualizer
- Its own, with no virtualization library; exported as
useVirtualizer - Row height
- Any, and different per row; each is measured once shown
- Tests
- Unit tests; end-to-end in Chromium, Firefox and WebKit, on desktop and phone viewports: virtual-list
When to use#
Use a VirtualList when a list runs to thousands of rows and should still scroll as one: a log, a feed of runs or deploys, the results of a long search. Rendering every row would put tens of thousands of elements in the page; a VirtualList renders the rows in view and six beyond each edge, about twenty in the list above, and swaps them as you scroll.
It renders children(item, index) for each row, so a row can be anything, and rows need not share a height. Give the list a height with className; it fills it and scrolls inside, with the themed scrollbars of ScrollArea.
Similar#
- Table is for records with columns and headers. It renders every row, so it suits a page of data rather than ten thousand rows.
- Combobox uses the same virtualizer by itself past 100 options; you do not need a VirtualList inside it.
- ScrollArea scrolls content that is fine to render whole. Reach for a VirtualList when there is too much of it.
Scroll to a row from code#
listRef receives scrollToIndex(index, { align }). "auto", the default, scrolls only as far as it takes to bring the row into view; "start", "center" and "end" put it there. The row stays in place while the rows around it are measured, for a second or until the reader turns the wheel, touches the list or clicks in it.
import { useRef, useState } from "react";
import { Button, VirtualList, type Virtualizer } from "cennge-ui";
interface Page {
number: number;
error?: string;
}
// A run of playwright-msi-scraper over 6,000 pages; a few of them timed out.
const pages: Page[] = Array.from({ length: 6_000 }, (_, index) => ({
number: index + 1,
error: index % 1_237 === 611 ? "Timeout 30000ms exceeded" : undefined,
}));
const failures = pages.flatMap((page, index) => (page.error ? [index] : []));
export default function NextFailure() {
const list = useRef<Pick<Virtualizer, "scrollToIndex">>(null);
const [current, setCurrent] = useState(-1);
const go = (step: 1 | -1) => {
const next = current < 0 && step < 0 ? failures.length - 1 : (current + step + failures.length) % failures.length;
setCurrent(next);
// "auto" scrolls only as far as it takes to bring the row into view.
list.current?.scrollToIndex(failures[next]!, { align: "auto" });
};
return (
<div className="flex w-full flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" variant="flat" onClick={() => go(-1)}>
Previous failure
</Button>
<Button size="sm" variant="flat" color="danger" onClick={() => go(1)}>
Next failure
</Button>
<span className="text-sm text-muted" aria-live="polite">
{current < 0 ? `${failures.length} of 6,000 pages failed` : `Failure ${current + 1} of ${failures.length}`}
</span>
</div>
<VirtualList
items={pages}
getKey={(page) => page.number}
estimateSize={30}
listRef={list}
aria-label="Scraped pages"
className="h-64 w-full rounded-large bg-surface-3"
>
{(page, index) => (
<div
className={`flex gap-4 px-4 py-1.5 font-mono text-xs ${index === failures[current] ? "bg-danger-bg" : ""}`}
>
<span className="w-20 shrink-0 tabular-nums">page {page.number}</span>
{page.error ? <span className="text-danger-text">{page.error}</span> : <span className="text-muted">saved</span>}
</div>
)}
</VirtualList>
</div>
);
}Rows of different kinds#
estimateSize is the height a row is given before it has been measured, in pixels. With rows of several kinds, such as day headings among deploys, pass a function of the index: the better the guess, the less the scrollbar shifts as rows are measured.
import { VirtualList } from "cennge-ui";
type Row =
| { kind: "day"; label: string }
| { kind: "deploy"; repository: string; environment: "production" | "preview"; time: string };
const repositories = ["cennge-ui", "ai-niche-scout", "payload-cache", "spredo-crypto-screener", "playwright-msi-scraper"];
const dayName = new Intl.DateTimeFormat("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric", timeZone: "UTC" });
// A year of deploys, newest first, each day under a heading row.
const rows: Row[] = Array.from({ length: 365 }, (_, dayIndex): Row[] => [
{ kind: "day", label: dayName.format(Date.UTC(2026, 10, 8 - dayIndex)) },
...Array.from({ length: 3 + (dayIndex % 4) }, (_, n): Row => ({
kind: "deploy",
repository: repositories[(dayIndex + n) % repositories.length]!,
environment: (dayIndex + n) % 3 ? "preview" : "production",
time: `${String(20 - n * 3).padStart(2, "0")}:${String((dayIndex * 7 + n * 13) % 60).padStart(2, "0")}`,
})),
]).flat();
export default function DeploysByDay() {
return (
<VirtualList
items={rows}
estimateSize={(index) => (rows[index]?.kind === "day" ? 34 : 40)}
aria-label="Deploys by day"
className="h-80 w-full max-w-md rounded-large bg-surface-3"
>
{(row) =>
row.kind === "day" ? (
<p className="px-4 pt-3 pb-1.5 text-xs font-semibold text-muted">{row.label}</p>
) : (
<div className="flex items-center gap-3 px-4 py-2 text-sm">
<span className="min-w-0 flex-1 truncate font-medium">{row.repository}</span>
<span className={row.environment === "production" ? "text-xs text-success-text" : "text-xs text-muted"}>
{row.environment}
</span>
<span className="w-11 text-right text-muted tabular-nums">{row.time}</span>
</div>
)
}
</VirtualList>
);
}Your own markup#
useVirtualizer is the engine under VirtualList and Combobox. Give it the item count and the scrolling element, render its items inside an element of totalSize, and pass measureElement as the ref of each row, with data-index. Roles, keys and focus are then yours: this log is a focusable region.
import { useState } from "react";
import { useVirtualizer } from "cennge-ui";
const paths = ["/api/posts", "/api/posts?page=2", "/api/media/cover.webp", "/api/users/me", "/api/globals/footer"];
// 20,000 lines of payload-cache's request log.
const lines = Array.from({ length: 20_000 }, (_, index) => {
const seconds = index * 0.37;
const time = new Date(Date.UTC(2026, 10, 8, 9) + seconds * 1000).toISOString().slice(11, 23);
const hit = index % 5 !== 0;
return `${time} ${hit ? "HIT " : "MISS"} ${paths[index % paths.length]} ${hit ? 2 + (index % 4) : 40 + (index % 90)} ms`;
});
export default function RequestLog() {
const [scrollElement, setScrollElement] = useState<HTMLDivElement | null>(null);
const virtualizer = useVirtualizer({ count: lines.length, estimateSize: 20, scrollElement, paddingStart: 8, paddingEnd: 8 });
return (
<div
ref={setScrollElement}
tabIndex={0}
role="region"
aria-label="Request log"
className="h-72 w-full overflow-auto rounded-large bg-surface-1 font-mono text-xs leading-5 outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-ring"
>
<div style={{ position: "relative", height: virtualizer.totalSize }}>
{virtualizer.items.map(({ index, start }) => (
<div
key={index}
ref={virtualizer.measureElement}
data-index={index}
className="flex gap-4 px-4"
style={{ position: "absolute", top: 0, left: 0, width: "100%", transform: `translateY(${start}px)` }}
>
<span className="w-10 shrink-0 text-right text-muted select-none">{index + 1}</span>
<span className="min-w-0 break-words whitespace-pre-wrap">{lines[index]}</span>
</div>
))}
</div>
</div>
);
}Accessibility#
- The rows sit in a
role="list", each alistitemwitharia-setsizeandaria-posinset, so screen readers can tell a row's place in the whole list, "4,812 of 10,000", though most rows are not in the page. - When the content overflows, the scrolling area takes Tab and the arrow keys scroll it. Name it with
aria-labeland it is a region screen readers can jump to. - Home and End on the list go to the exact first and last row. They are left alone when the focus is on a control inside a row.
- Text search in the browser (Ctrl+F) finds only the rendered rows. Give long lists their own search, and jump to the result with
scrollToIndex.