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)

Prerequisites

This guide assumes you are comfortable with the material it builds on and starts from a table that is already accessible before you window it:

  • A working semantic table. Start from the markup and header associations in Accessible Data Tables & Grids in React. Virtualization is a transformation applied to an accessible table—if the table is broken before you window it, windowing only hides the problem.
  • A virtualization library installed. The examples use react-window (FixedSizeList) and reference @tanstack/react-virtual. Either exposes an imperative scroll API, which is the piece focus management depends on.
  • Comfort with the grid keyboard model. Once you adopt role="grid" you owe users a single tab stop and arrow-key navigation between cells. If that model is new, review it in the parent guide before continuing.
  • A screen reader for verification. NVDA with Firefox, or VoiceOver with Safari. The whole point of this page is that automated tools cannot confirm the fix—you have to hear the announced row count and position yourself.

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.

How ARIA restores the truth a windowed table hides from the accessibility tree Three panels. Left: the full 50,001-row dataset, of which only a thin slice near row 8,400 is on screen. Middle: the DOM holds only that dozen-row window, so the focused row 8,400 is physically the fourth div. Right: the accessibility tree, where aria-rowcount declares the true total of 50,001 and aria-rowindex declares the absolute position, so a screen reader announces "row 8,400 of 50,001". Full dataset row 1 50,001 visible window In the DOM row 8,398 row 8,399 row 8,400 (focused) row 8,401 row 8,402 row 8,403 only ~12 rows exist here a screen reader would count just these Accessibility tree role="grid" aria-rowcount 50,001 role="row" aria-rowindex 8,400 screen reader announces "row 8,400 of 50,001" mount ARIA
What virtualization removes from the accessibility tree Three consequences of rendering only visible rows. The row count reported to assistive technology becomes the window size rather than the dataset size. Position information for each row is lost, so a user cannot tell where they are. Rows that scroll out of view unmount, which destroys focus if it was inside one. The reported row count becomes the window size, not the dataset size — restore it with aria-rowcount Position within the set lost unless each row carries aria-rowindex from the real index Rows that scroll away unmount, destroying focus that was inside them

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.

Because both strategies revolve around the same primitives—an "active index," a scroll call, and a deferred focus—they are a natural fit for a reusable custom hook. The broader machinery for tracking focus and scroll-into-view across recycled DOM is covered in React Hooks for Accessibility, which keeps this bookkeeping out of your grid component.


When virtualization is worth its accessibility cost Three cases. Under a few hundred rows, a plain table renders fast enough and keeps every accessibility property for free. In the thousands, virtualization is justified but must restore counts, indexes and focus handling. For unbounded datasets, pagination is usually the better answer for everyone, not only for assistive technology. Hundreds of rows render them all — the accessibility tree stays correct for free Thousands of rows virtualize, and restore aria-rowcount, aria-rowindex and focus handling Unbounded data paginate — a bounded page is easier for everyone, not only for screen readers

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 need to tame a large dataset, 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.


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.

Conclusion

Virtualization and accessibility are not in conflict, but they do not coexist by accident. Windowing removes rows from the DOM, and everything a screen reader knows comes from the DOM—so the moment you virtualize, you owe the accessibility tree the truth it can no longer read directly. Two disciplines cover it: declare the real dimensions with aria-rowcount and aria-rowindex (minding the +2 header offset), and never let focus fall to <body> when a row recycles—either pin real focus to the container with aria-activedescendant, or scroll-then-focus on the next frame. Reach for all of this only when the dataset genuinely demands it; a plain table or pagination is the more accessible answer far more often than teams assume. Verify with a real screen reader and a keyboard, because the one thing automated tooling cannot confirm is whether "row 8,400 of 50,001" is actually announced.


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.