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"/listitem or listbox/option, with aria-setsize (total) and aria-posinset (1-based position) on each rendered item.
  • Tables and grids: aria-rowcount on the table (total rows including headers) and aria-rowindex on 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.

What TanStack Virtual gives you and what you add Layers of a virtualized list: the virtualizer's index and position math, the semantic container and rows you render, the position metadata, and the focus-preserving range extension. What TanStack Virtual gives you and what you addVirtualizer (library)visible indices, offsets, total height, scrollToIndexSemantics (you)role="list" or a table; items as listitem or rowPosition (you)aria-setsize / aria-posinset, or aria-rowcount / aria-rowindexFocus (you)rangeExtractor keeps the focused index rendered
The library stops at geometry; everything a screen reader needs is in the three layers below it.

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
End key in a 12,480-item virtual list Timeline of pressing End in a virtualized listbox: active index updated, scrollToIndex renders the last item, next frame focuses it, screen reader announces position. End key in a 12,480-item virtual listEnd pressedactive = 12,479scrollToIndexvirtualizer renders the tailNext frameitem exists in the DOMfocus()roving tabindex movesAnnouncement"12,480 of 12,480"one keyboard jump
Scroll first, focus on the next frame — focusing an index that is not yet rendered does nothing.

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.

A default TanStack example versus an accessible one Comparison of the default TanStack Virtual demo markup against the accessible version with roles, position metadata, roving focus and a range extractor. A default TanStack example versus an accessible one✗ Default demo markupPositioned divs, no rolesNo total count exposedFocus lost when the item scrolls awayArrow keys scroll the container, not items✓ Accessible versionlistbox / option or table / row rolesaria-setsize and aria-posinset on each itemrangeExtractor keeps the focused indexArrows, Page keys, Home and End move focus
Same virtualizer, same performance — the difference is five attributes and one callback.

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.

Permalink to "Related"

← Back to Accessible Virtualized List Patterns