How to build an accessible VirtualList in React
The viewport is a role="list" and each rendered row is a role="listitem" carrying aria-setsize and aria-posinset for the entire collection, so assistive technology announces "row 3 of 10000" rather than the size of the rendered window — the defect that makes naive virtualization unusable with a screen reader. The viewport is also in the tab order, because rows hold plain content rather than focusable controls and a scroll container with nothing focusable inside it is otherwise unreachable without a pointer; once focused it takes the browser’s native arrow, Page and Home/End scrolling. Rows are placed with transform rather than by mutating layout, so scrolling stays on the compositor. Because unrendered rows are absent from the DOM, browser in-page search cannot reach them; provide a real filter or search control alongside any virtualized list, and prefer a non-virtualized list when the collection is small.
When to use a VirtualList
- Lists long enough that rendering every row costs noticeable time or memory (roughly a thousand rows and up)
- Log, result and feed views where the collection is already fully in memory
- Any list whose length is unbounded and rows are a uniform height
When not to use it
- Short lists — the machinery costs more than it saves; use List
- Rows of varying height, which this component cannot position without measuring
- Content that must be findable with the browser’s in-page search, which cannot see unrendered rows
Keyboard interactions
Role list, verified at WCAG 2.2-AA.
TabArrowUpArrowDownPageUpPageDownHomeEnd
Common mistakes
Avoid: Passing rows whose real height differs from `itemHeight`
Prefer: Fix the row height in CSS to match `itemHeight` exactly
Positions are arithmetic, not measured, so a mismatch makes rows overlap or leave gaps that grow with scroll depth
Avoid: Reaching for a percentage or `dvh` viewport height
Prefer: Measure the container and pass a px number
`height` drives the visible row count, which cannot be derived from a relative length without measuring
Avoid: Fetching the next page inside `renderItem`
Prefer: Pair with InfiniteScroll, or load before passing `items`
renderItem runs for every row entering the window, including on scroll-back, so a fetch there fires repeatedly
Example
<VirtualList
items={Array.from({ length: 10000 }, (_, i) => i)}
itemHeight={40}
height={320}
ariaLabel="Results"
renderItem={(n) => <span>Row {n + 1}</span>}
/>