Making TanStack Virtual Lists and Tables Accessible
Permalink to "Making TanStack Virtual Lists and Tables Accessible"TanStack Virtual (@tanstack/react-virtual, @tanstack/vue-virtual and siblings) is a headless virtualizer: it tells you which item indices are visible and where to position them, and renders nothing itself. That makes it flexible and fast. It also means every accessibility property of the list — its role, the true item count, each item’s position, and whether the focused item survives a scroll — is yours to add. A default TanStack Virtual example renders absolutely positioned <div>s inside a scrolling <div>, which a screen reader reads as a short run of unrelated text with no count and no position.
This page adds the missing semantics and the focus handling. It belongs to accessible virtualized list patterns, and parallels making react-window accessible for screen reader users.
Spec reference
Permalink to "Spec reference"The relevant TanStack Virtual API (v3): useVirtualizer({ count, getScrollElement, estimateSize, overscan, rangeExtractor }), returning getVirtualItems() (each with index, start, size, key), getTotalSize(), scrollToIndex(index, { align }) and measureElement for dynamic sizes. rangeExtractor lets you add indices to the rendered range — the hook for keeping a focused item alive.
On the ARIA side:
- Lists:
role="list"/listitemorlistbox/option, witharia-setsize(total) andaria-posinset(1-based position) on each rendered item. - Tables and grids:
aria-rowcounton the table (total rows including headers) andaria-rowindexon each rendered row (1-based, headers included).
Criteria: SC 1.3.1 Info and Relationships (true size and position), SC 2.1.1 Keyboard, SC 2.4.3 Focus Order (focus must not be destroyed by scrolling), and SC 4.1.3 for loading and count messages.
When to virtualize — and when not to
Permalink to "When to virtualize — and when not to"Virtualize when the DOM cost of rendering every row is real: thousands of rows, complex cells, or rows that are re-rendered often. The trade-offs for screen reader users are set out in DOM size limits and performance tradeoffs.
Do not virtualize a few hundred simple rows. Modern browsers handle that DOM comfortably, and a fully rendered list lets screen reader users read, search and navigate everything with their own commands. Virtualization always removes some of that.
The misapplication to name is virtualizing a static table that users read in browse mode. Browse-mode reading moves through the DOM; rows that are not rendered are not reachable, and the reader’s virtual cursor cannot trigger the scroll that would render them. If users must read every row, paginate instead — see pagination versus virtualization for large tables.
Annotated code example
Permalink to "Annotated code example"import { useRef, useState, useCallback } from 'react';
import { useVirtualizer, defaultRangeExtractor } from '@tanstack/react-virtual';
export function VirtualLog({ items }) {
const parent = useRef(null);
const [active, setActive] = useState(0); // focused index
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parent.current,
estimateSize: () => 36,
overscan: 8,
// SC 2.4.3: always render the focused index so focus is never destroyed
rangeExtractor: useCallback((range) => {
const idx = new Set(defaultRangeExtractor(range));
idx.add(active);
return [...idx].sort((a, b) => a - b);
}, [active]),
});
const move = (next) => {
const i = Math.max(0, Math.min(items.length - 1, next));
setActive(i);
virtualizer.scrollToIndex(i, { align: 'auto' }); // render it, then focus
requestAnimationFrame(() => parent.current
?.querySelector(`[data-index="${i}"]`)?.focus());
};
const onKeyDown = (e) => {
const page = Math.floor(parent.current.clientHeight / 36);
const map = { ArrowDown: active + 1, ArrowUp: active - 1, PageDown: active + page,
PageUp: active - page, Home: 0, End: items.length - 1 };
if (e.key in map) { e.preventDefault(); move(map[e.key]); }
};
return (
<div ref={parent} className="virtual-scroll" style={{ height: 480, overflow: 'auto' }}>
{/* SC 1.3.1: a real listbox with a name */}
<div role="listbox" aria-label={`Activity log, ${items.length} entries`}
onKeyDown={onKeyDown}
style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((v) => (
<div key={v.key} data-index={v.index} ref={virtualizer.measureElement}
role="option"
aria-setsize={items.length} // SC 1.3.1: the true total
aria-posinset={v.index + 1} // 1-based position
aria-selected={v.index === active}
tabIndex={v.index === active ? 0 : -1} // roving tabindex
style={{ position: 'absolute', top: 0, left: 0, width: '100%',
transform: `translateY(${v.start}px)` }}>
{items[v.index].text}
</div>
))}
</div>
</div>
);
}
The rangeExtractor is the piece most implementations miss. When the user focuses an item and then scrolls with the mouse wheel or the scrollbar, the focused item leaves the rendered range; without the extractor, React unmounts it and focus falls to <body>. Adding the active index to every range keeps that one element alive however far the user scrolls.
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Key / event | Expected announcement | AT-specific deviations |
|---|---|---|
Tab into the list |
“Activity log, 12,480 entries, list box. Build started, 1 of 12,480” | VoiceOver may omit the set size until the second item |
Down Arrow |
“Tests passed, 2 of 12,480” | — |
End |
Scrolls and focuses: “Deploy finished, 12,480 of 12,480” | Needs the rAF focus after scroll |
| Mouse-wheel scroll far away | Focus stays on the active item (still rendered) | Without rangeExtractor, focus lost |
| NVDA browse-mode reading | Only rendered items reachable | By design — offer a full view elsewhere |
Integration context
Permalink to "Integration context"For a virtual table rather than a list, the same structure uses aria-rowcount on the <table> (or role="grid") and aria-rowindex on each <tr>, counting the header row as row 1. The rules for partial grids are in aria-colcount and aria-rowcount for partial grids. Position metadata is covered in more depth in aria-setsize and aria-posinset in virtual lists.
When items stream in — infinite loading — announce new totals through the page’s status region rather than letting the set size change silently, as in announcing streamed rows as data hydrates.
Gotchas
Permalink to "Gotchas"transform and reading order. Items positioned with transform keep their DOM order, which should match index order. If you reorder DOM for recycling, reading order breaks; TanStack does not, but custom wrappers sometimes do.
Dynamic heights. With measureElement, sizes change after render and the virtualizer re-positions items. Scroll-then-focus still works, but measure after fonts load or the first jumps land short.
Find in page. Browser find (Ctrl+F) cannot find unrendered items. Provide an in-app search that uses scrollToIndex.
Design system notes
Permalink to "Design system notes"Wrap the virtualizer in a design-system VirtualList and VirtualTable that require a label and a role choice, set position metadata automatically, include the focused-index range extension, and implement the keyboard model. Product teams then get virtualization without re-solving the accessibility layer — which, in practice, they otherwise skip.
Testing checklist
Permalink to "Testing checklist"FAQ
Permalink to "FAQ"Is TanStack Virtual accessible?
It is headless, so it renders no semantics at all. It becomes accessible when you add list or table roles, total and position attributes, a keyboard model, and a range extractor that keeps the focused item rendered.
How do screen readers know the total number of items in a virtual list?
From aria-setsize on each rendered item for lists, or aria-rowcount on the table or grid with aria-rowindex on each row. Without them, readers count only the rendered items.
Why does focus disappear when I scroll a virtual list?
Because the focused item left the rendered range and was unmounted. Use rangeExtractor to always include the focused index in the rendered set.
Can screen reader users read a virtualized list in browse mode?
Only the rendered part. Browse mode cannot trigger rendering of off-screen items, so provide keyboard navigation inside the widget and a non-virtual route, such as pagination or export, for full reading.
Related
Permalink to "Related"- Making react-window accessible — the same problems in react-window
- aria-setsize and aria-posinset — position metadata in depth
- aria-rowcount for partial grids — the grid counterpart