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

When not to use it

Keyboard interactions

Role list, verified at WCAG 2.2-AA.

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>}
/>

See the full VirtualList reference →