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-rowcounton the grid declares the total number of rows in the full dataset, not the number currently in the DOM. Use-1only if the total is genuinely unknown (e.g., infinite streaming).aria-rowindexon 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-colcountandaria-colindexdo 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 witharia-activedescendantpointing at the active cell'sid. 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 updatearia-activedescendantto its id.- Roving
tabindexwith scroll-into-view. Give only the active celltabIndex={0}and the resttabIndex={-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 thataria-rowcountandaria-rowindexcarry 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-activedescendantor 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 thataria-rowcountequalsrows.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-rowindexmatches its true dataset position, and that the grid'saria-rowcountequals 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-rowindexthat 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.