Skip to content
cennge-ui
GitHubv0.3.1, changelog

VirtualList

A list of thousands of rows that scrolls like any other, with only the rows in view in the page.

cennge-ui #10,000

40s

ai-niche-scout #9,999

1m 17s

payload-cache #9,998

1m 54s

spredo-crypto-screener #9,997

Typecheck exited with code 2

2m 31s

playwright-msi-scraper #9,996

3m 8s

cennge-ui #9,995

3m 45s

ai-niche-scout #9,994

4m 22s

payload-cache #9,993

4m 59s

spredo-crypto-screener #9,992

5m 36s

playwright-msi-scraper #9,991

6m 13s

cennge-ui #9,990

Playwright · webkit: 1 test failed

6m 50s

ai-niche-scout #9,989

7m 27s

payload-cache #9,988

1m 4s

spredo-crypto-screener #9,987

1m 41s

playwright-msi-scraper #9,986

2m 18s

cennge-ui #9,985

2m 55s

ai-niche-scout #9,984

3m 32s

payload-cache #9,983

Build cancelled after 10 minutes

4m 9s
  1. Scroll through 10,000 runs: only the rows in view, and six beyond each edge, are rendered.
  2. Failed runs take a second line. Each row is measured as it appears, and the rows in view hold still.
  3. Tab to the list: End and Home go to the last and first run. Or type a run number and go to it.
Live: the component itself, not a picture of 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 carry aria-setsize and aria-posinset, in a ScrollArea
Keyboard
HomeEnd
State
None to hold: items in, rows out; scrollToIndex through listRef
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.

5 of 6,000 pages failed
page 1saved
page 2saved
page 3saved
page 4saved
page 5saved
page 6saved
page 7saved
page 8saved
page 9saved
page 10saved
page 11saved
page 12saved
page 13saved
page 14saved
page 15saved
page 16saved
page 17saved
page 18saved
page 19saved
page 20saved
page 21saved
page 22saved
page 23saved
page 24saved
page 25saved
page 26saved
page 27saved
next-failure.tsx
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.

Sunday, November 8, 2026

cennge-uiproduction20:00
ai-niche-scoutpreview17:13
payload-cachepreview14:26

Saturday, November 7, 2026

ai-niche-scoutpreview20:07
payload-cachepreview17:20
spredo-crypto-screenerproduction14:33
playwright-msi-scraperpreview11:46

Friday, November 6, 2026

payload-cachepreview20:14
spredo-crypto-screenerproduction17:27
playwright-msi-scraperpreview14:40
cennge-uipreview11:53
ai-niche-scoutproduction08:06

Thursday, November 5, 2026

spredo-crypto-screenerproduction20:21
playwright-msi-scraperpreview17:34
cennge-uipreview14:47
ai-niche-scoutproduction11:00
payload-cachepreview08:13
spredo-crypto-screenerpreview05:26
grouped.tsx
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.

109:00:00.000 MISS /api/posts 40 ms
209:00:00.370 HIT /api/posts?page=2 3 ms
309:00:00.740 HIT /api/media/cover.webp 4 ms
409:00:01.110 HIT /api/users/me 5 ms
509:00:01.480 HIT /api/globals/footer 2 ms
609:00:01.850 MISS /api/posts 45 ms
709:00:02.220 HIT /api/posts?page=2 4 ms
809:00:02.590 HIT /api/media/cover.webp 5 ms
909:00:02.960 HIT /api/users/me 2 ms
1009:00:03.330 HIT /api/globals/footer 3 ms
1109:00:03.700 MISS /api/posts 50 ms
1209:00:04.070 HIT /api/posts?page=2 5 ms
1309:00:04.440 HIT /api/media/cover.webp 2 ms
1409:00:04.810 HIT /api/users/me 3 ms
1509:00:05.180 HIT /api/globals/footer 4 ms
1609:00:05.550 MISS /api/posts 55 ms
1709:00:05.920 HIT /api/posts?page=2 2 ms
1809:00:06.290 HIT /api/media/cover.webp 3 ms
1909:00:06.660 HIT /api/users/me 4 ms
2009:00:07.030 HIT /api/globals/footer 5 ms
2109:00:07.400 MISS /api/posts 60 ms
2209:00:07.770 HIT /api/posts?page=2 3 ms
2309:00:08.140 HIT /api/media/cover.webp 4 ms
2409:00:08.510 HIT /api/users/me 5 ms
2509:00:08.880 HIT /api/globals/footer 2 ms
2609:00:09.250 MISS /api/posts 65 ms
2709:00:09.620 HIT /api/posts?page=2 4 ms
2809:00:09.990 HIT /api/media/cover.webp 5 ms
2909:00:10.360 HIT /api/users/me 2 ms
3009:00:10.730 HIT /api/globals/footer 3 ms
3109:00:11.100 MISS /api/posts 70 ms
3209:00:11.470 HIT /api/posts?page=2 5 ms
3309:00:11.840 HIT /api/media/cover.webp 2 ms
3409:00:12.210 HIT /api/users/me 3 ms
3509:00:12.580 HIT /api/globals/footer 4 ms
3609:00:12.950 MISS /api/posts 75 ms
request-log.tsx
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 a listitem with aria-setsize and aria-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-label and 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.

Props#

PropTypeDefault
itemsrequired
readonly T[]
–
childrenrequired
(item: T, index: number) => React.ReactNodeRenders one item. Only items in view (and a few around) are rendered.
–
getKey
(item: T, index: number) => React.KeyA stable key per item, e.g. its id.
the index
estimateSize
number | ((index: number) => number)Height of an item before it is measured, in px. Items may differ; each is measured once shown.
40
overscan
number
6
listRef
React.Ref<Pick<Virtualizer, "scrollToIndex">>Receives scrollToIndex, e.g. to jump to a search result.
–
listClassName
stringClasses for the element that holds the items.
–
type
"auto""hover""scroll""always"When the scrollbars show: on hover, while scrolling, whenever the content overflows, or always. On touch screens, which have no hover, "hover" shows them while scrolling.
"hover"
classNames
Partial<Record<"base" | "viewport" | "scrollbar" | "thumb", string>>
–
scrollHideDelay
numberHow long scrollbars stay after the pointer leaves or scrolling stops, in ms.
600
fadeEdges
booleanFade the content out at edges that hide more of it, so it is clear there is more to scroll.
–
fadeSize
stringLength of the fade.
"1.5rem"