Skip to content

VirtualList

Terminal window
$ npx shadcn@latest add https://lacodda.github.io/dowel/r/virtual-list.json
See it liveA hundred thousand rows, taller rows, and none at all.→

Why it exists. The browser is fine with long lists until it is not: a hundred thousand <div>s is a layout the machine recomputes on every change, and the page stops responding while it does. What is drawn instead is the window the reader can see, held in place by a tall spacer so the scrollbar still says how much there is.

<VirtualList
rows={rows}
rowHeight={32}
rowKey={(row) => row.id}
label="Works"
className="h-64"
>
{(row, index) => <Line row={row} index={index} />}
</VirtualList>

No virtualisation library. react-window, react-virtuoso and @tanstack/react-virtual all solve the general problem — variable heights measured at runtime, horizontal windows, grids — and the general problem is not the one a design system has: every long list in a product is a column of rows of one height. That case is windowFor, and it is small enough to read.

rowHeight is given, never measured, and variable heights are deliberately not supported. Measuring means rendering to find out, which means a second pass, a cache, and a scrollbar that changes length as the reader travels. A product that needs that needs a library rather than a bigger version of this.

The arithmetic is exported, and that is not tidiness. A window computed inside an effect can only be tested by a browser, and jsdom gives every element a height of zero — a test would assert against a list that believes it is invisible and pass whatever the code did. As a plain function the rules are checkable: given a scroll offset and a height, these rows are drawn.

windowFor({ count: 100_000, rowHeight: 32, viewportHeight: 400, scrollTop: 0 })
// → { start: 0, end: 17, totalHeight: 3_200_000, offsetTop: 0 }

overscan is not decoration. The scroll event arrives after the pixels, so without extra rows beyond each edge a fast scroll paints blank where nothing has been drawn yet.

rowKey is required. Without it React keys by index, and a list that is sorted or filtered reuses the wrong DOM node — the row moves but the input inside it keeps the text that was typed into a different row.

It announces its real size. aria-setsize and aria-posinset on each row, because the rows in the DOM are a screenful: counted from those, a reader would say “3 of 20” in a list of a hundred thousand. Not aria-rowcount / aria-rowindex, which look like the right names and belong to grid, table and treegrid — on a list they are unsupported.

The viewport is watched, not measured once. A list inside a panel that opens, a window the reader resizes, a font that loads late — all change the height after mount, and a list that measured once draws the wrong number of rows until something scrolls it.

windowFor is exported on its own. windowFor({ count, rowHeight, viewportHeight, scrollTop, overscan? }) returns { start, end, totalHeight, offsetTop } - for a product windowing its own rows.

Props

Read from the types in ui/virtual-list.tsx, the file a product copies - so the description of each prop is also what an editor shows over it there.

PropTypeDefault
rowsrequiredreadonly Row[]Every row there is, in order. Only the ones in view are drawn.
rowHeightrequirednumberHow tall one row is. Fixed, and the component enforces it - a row that grows past this height overlaps its neighbour, which is why the value is required rather than measured.
childrenrequired(row: Row, index: number) => ReactNode
rowKeyrequired(row: Row, index: number) => string | numberA stable identity per row. Without it React keys by index, and a list that is sorted or filtered reuses the wrong DOM node - the row moves but its input keeps the text that was typed into a different row.
overscannumber3How many extra rows to draw beyond each edge, so a fast scroll does not paint blank.
labelrequiredstringWhat a screen reader calls the list. Required: a scrolling box of rows with no name is announced as "list" and nothing else, and the word is the product's to give.
classNamestring

Accessibility

The examples on the stand, through axe 4.14.0 in a browser, in both themes, against WCAG 2.2 A and AA and axe's best practices: 18 rules applied and passed, and none failed. The accessibility report has every item.

Left to a person, where axe could not decide: aria-required-children, color-contrast.

History

Each release that changed ui/virtual-list.tsx - the file a product copies - newest first.

  1. v0.41.0
    • docs read every props table from the component's types ee3b159
  2. v0.22.0
    • feat a long list, a deep one, and the facts beside them 4cbd42e