react nextjs accessibility patterns

Virtualizing Long Lists Accessibly in React

Virtualization makes 50,000-row tables fast by rendering only the handful of rows currently on screen. It also quietly destroys accessibility: the DOM now holds a dozen rows instead of fifty thousand, so a screen reader reports a tiny table, focused rows vanish as the user scrolls, and the structure assistive technology relies on falls apart. This guide shows how to virtualize with react-window or @tanstack/react-virtual while preserving the true size of the data and keeping focus stable as rows recycle.

This is a deep dive under Accessible Data Tables & Grids in React. Read that guide first for the semantic-table foundations and the discussion of when role="grid" is appropriate—virtualization is one of the few cases where it genuinely is.

Mapped WCAG Success Criteria:

  • 1.3.1 Info and Relationships (Level A)
  • 4.1.2 Name, Role, Value (Level A)
  • 2.1.1 Keyboard (Level A)
  • 2.4.3 Focus Order (Level A)

Why Virtualization Breaks the Accessibility Tree

The accessibility tree is built from the DOM. Virtualization's entire purpose is to keep most of the DOM from existing—only the rows in (and just around) the viewport are mounted; the rest are absent. Three things break as a result:

  • Size is wrong. A screen reader counts the rows it can see in the DOM. With windowing, that might be 12 rows of a 50,000-row dataset, so the user is told the table has 12 rows. The relationship between a row and the whole dataset, required by 1.3.1 Info and Relationships, is lost.
  • Position is wrong. Even if a row is rendered, nothing tells the user it is row 8,400 of 50,000. "Row 5 of 12" is actively misleading.
  • Focus disappears. When the user scrolls or navigates, the row that had focus unmounts. The browser drops focus to <body>, stranding keyboard users.

The fix is to tell assistive technology the truth about the data even though the DOM only holds a slice—via aria-rowcount and aria-rowindex—and to manage focus deliberately as rows recycle.


Restoring True Size With aria-rowcount and aria-rowindex

ARIA provides attributes precisely for "the DOM holds fewer rows than really exist." They are defined for grid and table roles, so a virtualized data table that needs them adopts role="grid" (or keeps a native <table> augmented with these attributes). This satisfies 4.1.2 Name, Role, Value by reporting the real dimensions:

  • aria-rowcount on the grid declares the total number of rows in the full dataset, not the number currently in the DOM. Use -1 only if the total is genuinely unknown (e.g., infinite streaming).
  • aria-rowindex on each rendered row declares its 1-based position within the full dataset, so row 8,400 announces as row 8,400 even though it is the third <div> in the DOM.
  • aria-colcount and aria-colindex do the same for columns when columns are also virtualized.
'use client';
import { FixedSizeList, type ListChildComponentProps } from 'react-window';

const COLUMNS = ['Region', 'Revenue', 'Growth'];

export function VirtualGrid({ rows }: { rows: Row[] }) {
  return (
    // aria-rowcount = the TRUE total, not the windowed count.
    // +1 accounts for the header row, which is also counted.
    <div
      role="grid"
      aria-label="Revenue by region"
      aria-rowcount={rows.length + 1}
      aria-colcount={COLUMNS.length}
    >
      {/* Header row is aria-rowindex 1 */}
      <div role="row" aria-rowindex={1} className="vg-header">
        {COLUMNS.map((c, i) => (
          <span role="columnheader" aria-colindex={i + 1} key={c}>{c}</span>
        ))}
      </div>

      <FixedSizeList
        height={480}
        itemCount={rows.length}
        itemSize={40}
        width="100%"
      >
        {({ index, style }: ListChildComponentProps) => {
          const row = rows[index];
          return (
            <div
              role="row"
              style={style}
              // +2: header is row 1, and aria-rowindex is 1-based
              aria-rowindex={index + 2}
            >
              <span role="gridcell" aria-colindex={1}>{row.region}</span>
              <span role="gridcell" aria-colindex={2}>{row.revenue}</span>
              <span role="gridcell" aria-colindex={3}>{row.growth}</span>
            </div>
          );
        }}
      </FixedSizeList>
    </div>
  );
}

With this in place, a screen reader announces "row 8,400 of 50,001" for a row that is physically the third element in the DOM. The +2 offset on aria-rowindex is the detail people get wrong: the header occupies index 1, and aria-rowindex is 1-based, so the first data row (array index 0) is aria-rowindex={2}.

Note that when you adopt role="grid", you take on the grid keyboard model: a single tab stop into the grid and arrow-key navigation between cells via roving tabindex or aria-activedescendant. Do not adopt the grid role and then leave cells individually tabbable—that mixes two interaction models and confuses everyone.


Focus Management When Rows Unmount

The hardest virtualization problem is focus. If the focused row scrolls out of the window, react-window unmounts it, and focus silently falls to <body>—a 2.4.3 Focus Order and 2.1.1 Keyboard failure. There are two robust strategies:

  • aria-activedescendant (recommended for grids). Keep DOM focus on the grid container at all times and track the "active" cell with aria-activedescendant pointing at the active cell's id. Because real focus never leaves the container, unmounting a row cannot strip it. On arrow-key navigation, scroll the target row into view first, then update aria-activedescendant to its id.
  • Roving tabindex with scroll-into-view. Give only the active cell tabIndex={0} and the rest tabIndex={-1}. Before moving focus to a row that may be outside the window, call the virtualizer's scroll API (listRef.current.scrollToItem(index)) so the target is mounted, then .focus() it on the next frame.
// Roving-tabindex move that survives windowing: scroll first, then focus.
function moveTo(index: number) {
  setActiveIndex(index);
  listRef.current?.scrollToItem(index, 'smart'); // ensure the row is mounted
  requestAnimationFrame(() => {
    const el = document.getElementById(`row-${index}`);
    el?.focus(); // safe now that the row exists in the DOM
  });
}

The non-negotiable rule: never call .focus() on a row index that the virtualizer has not yet mounted. Scroll it into view first, wait one frame, then focus. Skipping the scroll is the single most common cause of "focus jumps to the top of the page" bugs in virtualized lists.


Don't Virtualize When a Plain Table Suffices

Virtualization is an optimization with a real accessibility cost, so apply it only when the dataset actually demands it. A few hundred rows render and scroll fine in a plain semantic <table>, which is more robust, fully navigable by assistive technology with zero extra ARIA, and far less code. Reaching for react-window on a 200-row table trades correctness for a performance win you do not need.

A reasonable rule of thumb: keep a plain <table> until row counts climb into the low thousands or you measure a genuine scroll/render problem. When you do virtualize, prefer paginating the data instead where the UX allows—pagination, covered in accessible pagination for React data tables, keeps a small, fully-rendered, fully-semantic table on screen and sidesteps the entire windowing problem. Virtualize only when users genuinely need to scroll one continuous very long list.

For the broader machinery—custom hooks for focus tracking and scroll-into-view across recycled DOM—see React Hooks for Accessibility.


How to Verify

  • Automated (axe-core / jest-axe): Assert no violations and check for invalid grid structure. axe flags a role="grid" missing required structure, but it cannot verify that aria-rowcount and aria-rowindex carry the true dataset values—that is a manual or unit-test check.
  • Size and position (screen reader): With NVDA + Firefox or VoiceOver + Safari, navigate to a row deep in the list. Confirm the screen reader announces the correct total ("of 50,001") and the correct absolute position ("row 8,400"), not the windowed count.
  • Focus across recycling (keyboard): Move focus to a row, then scroll far enough that the row unmounts. Confirm focus is preserved—either still on the grid container via aria-activedescendant or moved deliberately—never dropped to <body>. Arrow from the top to a row well outside the initial window and confirm focus follows.
  • Unit test the offsets: Assert that the first data row has aria-rowindex={2} (header is 1) and that aria-rowcount equals rows.length + 1. Off-by-one errors here are common and silent.

Testing Hook: Render the virtual grid with a known large dataset, query the last mounted row, and assert its aria-rowindex matches its true dataset position, and that the grid's aria-rowcount equals the full total plus the header.


Common a11y Mistakes

  • Virtualizing without aria-rowcount/aria-rowindex, so assistive technology reports only the windowed slice and the true dataset size is lost.
  • Off-by-one aria-rowindex that forgets the header occupies index 1, so every announced position is wrong.
  • Letting focus drop to <body> when the focused row unmounts during scroll.
  • Calling .focus() on a row the virtualizer has not mounted yet instead of scrolling it into view first.
  • Adopting role="grid" but leaving every cell individually tabbable, mixing the grid keyboard model with normal tabbing.
  • Virtualizing a few-hundred-row table that a plain semantic <table>—or simple pagination—would handle with no accessibility cost.

Frequently Asked Questions

Why does react-window break my table for screen readers? Windowing renders only the rows near the viewport, so the DOM—and therefore the accessibility tree—holds a small slice of the data. A screen reader counts the rows it can see and reports that count, so a 50,000-row dataset announces as a dozen rows, and individual rows have no sense of their true position. You must restore the real dimensions with aria-rowcount and aria-rowindex.

What is the difference between aria-rowcount and the number of rows in the DOM?aria-rowcount declares the total number of rows in the full dataset, including the header row, regardless of how many are currently rendered. The DOM holds only the windowed slice. Reporting the true total via aria-rowcount is what lets assistive technology tell the user the table has 50,001 rows even though only a handful exist in the DOM, satisfying 4.1.2 Name, Role, Value.

How do I keep keyboard focus from disappearing when a row scrolls out of view? Either keep real focus on the grid container and track the active cell with aria-activedescendant, which cannot be lost because focus never leaves the container, or use roving tabindex and always scroll the target row into view with the virtualizer's scroll API before calling .focus() on the next frame. Both prevent the browser from dropping focus to <body> when a focused row unmounts, satisfying 2.4.3 Focus Order and 2.1.1 Keyboard.

When should I avoid virtualization for accessibility reasons? When the dataset is small enough to render fully without a measured performance problem—roughly up to the low thousands of rows. A plain semantic <table> is fully accessible with no extra ARIA and far less code. Where the UX allows, prefer pagination over virtualization, since it keeps a small, fully-rendered, fully-semantic table on screen and avoids the windowing accessibility problems entirely.