Making react-window Accessible for Screen Reader Users
Permalink to "Making react-window Accessible for Screen Reader Users"react-window recycles DOM nodes as rows scroll in and out of the viewport — the single behaviour that makes it fast is the same one that breaks screen reader parsing. Without explicit position metadata, assistive technologies count only the handful of rendered rows and conclude the list is that short. This page documents the precise ARIA injections, keyboard focus wiring, and live region patterns that restore full semantic continuity, satisfying WCAG 2.2 success criteria 1.3.1, 4.1.2, 4.1.3, and 2.4.3.
Spec Reference
Permalink to "Spec Reference"The two attributes that solve the virtualization position problem are defined in the ARIA specification:
| Attribute | Valid values | Default | Purpose |
|---|---|---|---|
aria-setsize |
Integer ≥ 0 or -1 (unknown) |
Derived from DOM | Declares the total number of items in the logical set |
aria-posinset |
Integer 1–N | Derived from DOM | Declares this item’s 1-based position within that set |
aria-selected |
true / false / undefined |
undefined |
Required on role="option" to expose selection state (WCAG 4.1.2) |
aria-label |
String | — | Provides the accessible name for the container (WCAG 4.1.2) |
When react-window renders a viewport slice of 15 rows from a 5 000-item dataset, the DOM contains only 15 nodes. A screen reader inferring position from DOM order will announce “1 of 15” even when the user has scrolled to row 3 000. Injecting aria-posinset={index + 1} and aria-setsize={items.length} on each row overrides that inference with the true values.
When to Use vs. When Not to Use These Patterns
Permalink to "When to Use vs. When Not to Use These Patterns"Use aria-setsize / aria-posinset when:
- The visible DOM represents a subset of a larger logical collection (any virtualized list).
- Rows are recycled or unmounted when off-screen.
- The list can be filtered or paginated, changing the logical set size.
Do not apply these attributes when:
- The full dataset is in the DOM (no virtualization) — browsers derive correct values automatically.
- You are building a
role="grid"orrole="treegrid"— usearia-rowcount/aria-rowindexinstead (those apply to grid semantics, not list semantics). - The list length is unknown and asynchronously loaded — use
aria-setsize="-1"temporarily, then update once the count resolves.
A common misapplication is setting aria-setsize on the container element rather than on individual role="option" or role="listitem" nodes. Screen readers read these attributes from the item, not the container.
Annotated Code Example
Permalink to "Annotated Code Example"The diagram below shows how react-window’s rendered viewport slice maps to the ARIA attributes that restore the full-dataset context for assistive technologies.
The implementation passes the total count and active index through itemData so each row has all the information it needs without reading from a context provider on every render cycle:
// Row component — each prop maps to a WCAG / ARIA requirement
const Row = ({ index, style, data }) => {
const { items, activeIndex, onSelect } = data;
const item = items[index];
const isActive = index === activeIndex;
return (
<div
style={style}
role="option" // ARIA: valid child of role="listbox"
aria-posinset={index + 1} // ARIA: 1-based absolute position in full dataset
aria-setsize={items.length}// ARIA: total logical set size, not DOM node count
aria-selected={isActive} // WCAG 4.1.2: programmatically expose selection state
id={`row-${index}`}
tabIndex={isActive ? 0 : -1} // Roving tabindex — only one node in tab order
onClick={() => onSelect(index)}
>
{item.label}
</div>
);
};
// Container — role="listbox" wraps role="option" children (ARIA owns relationship)
<FixedSizeList
ref={listRef}
role="listbox" // ARIA: composite widget for selectable options
tabIndex={0} // Receives initial focus before roving tabindex kicks in
aria-label="Search Results"// WCAG 4.1.2: accessible name on the container
aria-multiselectable={false}
itemData={{ items, activeIndex, onSelect }}
itemCount={items.length}
itemSize={48}
height={400}
width="100%"
>
{Row}
</FixedSizeList>
API version note: These examples use the
react-window1.x API (FixedSizeList/VariableSizeList). Version 2 replaces them with a unifiedListcomponent acceptingrowComponentandrowProps. The ARIA techniques here transfer unchanged — only the component wiring differs.
Keyboard and AT Behaviour
Permalink to "Keyboard and AT Behaviour"| Key | Action | Expected announcement (NVDA/Firefox) | Failure indicator |
|---|---|---|---|
Tab |
Move focus to container | “Search Results listbox” | No role announced → missing role="listbox" |
ArrowDown |
Advance active index | “Item name, 3000 of 5000, not selected” | Announces “1 of 15” → missing aria-posinset / aria-setsize |
ArrowUp |
Retreat active index | “Item name, 2999 of 5000, not selected” | Focus jumps to page elements → missing e.preventDefault() |
Enter / Space |
Select active item | “Item name, selected” | No state change announced → missing aria-selected update |
PageDown |
Jump +10 items | “Item name, 3010 of 5000” | No scroll → missing listRef.current.scrollToItem() call |
Home |
Jump to first item | “Item name, 1 of 5000” | Focus stays at previous position → setActiveIndex(0) not called |
End |
Jump to last item | “Item name, 5000 of 5000” | — |
Screen reader compatibility matrix:
| AT + Browser | aria-posinset announcement |
Known deviation |
|---|---|---|
| NVDA 2024 + Firefox | “X of Y” in browse and application mode | None in FF; Chrome may skip the count on rapid key presses |
| JAWS 2024 + Chrome | “X of Y” with item content | Requires aria-selected to be explicitly false, not absent |
| VoiceOver + Safari (macOS 14) | “X of Y” via rotor | Rotor “Lists” entry uses aria-label on container, not aria-setsize |
| VoiceOver + Safari (iOS 17) | “X of Y” on swipe | Works; touch-scroll must also call scrollToItem |
| TalkBack + Chrome (Android) | “X of Y” | Requires role="list" hierarchy for flat lists; role="listbox" works for selectable |
Keyboard Focus Synchronisation
Permalink to "Keyboard Focus Synchronisation"Implementing roving tabindex for custom data grids explains the general pattern; react-window adds one complication — the DOM node for activeIndex may not exist yet if the item is off-screen. Always call scrollToItem before reading the DOM node for focus:
const handleKeyDown = (e) => {
const lastIndex = items.length - 1;
let next = activeIndex;
switch (e.key) {
case 'ArrowDown': next = Math.min(activeIndex + 1, lastIndex); break;
case 'ArrowUp': next = Math.max(activeIndex - 1, 0); break;
case 'PageDown': next = Math.min(activeIndex + 10, lastIndex); break;
case 'PageUp': next = Math.max(activeIndex - 10, 0); break;
case 'Home': next = 0; break;
case 'End': next = lastIndex; break;
default: return; // Do not preventDefault for non-navigation keys
}
e.preventDefault(); // WCAG 2.1.1: keyboard must not be trapped by default browser scroll
setActiveIndex(next);
};
useEffect(() => {
if (activeIndex === null || !listRef.current) return;
// scrollToItem(index, align) — positional args, not an object (react-window 1.x)
listRef.current.scrollToItem(activeIndex, 'smart'); // 'smart' = minimum scroll needed
}, [activeIndex]);
After scrollToItem completes, the row node exists in the DOM. A subsequent useEffect (or a MutationObserver on the list container) can then call .focus() on the node with id="row-${activeIndex}" to place physical focus there for the roving-tabindex pattern.
Integration Context
Permalink to "Integration Context"This page is a focused implementation reference within Accessible Virtualized List Patterns, which covers the full spectrum of virtualization challenges: how to choose between fixed-height and variable-height virtualization, how to handle grouped rows, and how to expose grid semantics for tabular virtualized data.
For the live region half of the picture — announcing filter results, page loads, and item counts without interrupting the user — the aria-live regions for dynamic data cluster covers aria-live politeness levels, aria-atomic, and collision avoidance in detail. The short version for this page: build a useAnnounce hook with a 400 ms debounce so rapid state updates do not flood the announcement queue:
const useAnnounce = () => {
const [message, setMessage] = useState('');
const timerRef = useRef(null);
const announce = useCallback((text) => {
clearTimeout(timerRef.current);
// Debounce prevents queue flooding during fast keyboard traversal (WCAG 4.1.3)
timerRef.current = setTimeout(() => setMessage(text), 400);
}, []);
useEffect(() => () => clearTimeout(timerRef.current), []);
return { message, announce };
};
// Render the live region — place outside the virtualized list in the DOM
// role="status" is equivalent to aria-live="polite" + aria-atomic="true"
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only" // Visually hidden; screen readers still read it
>
{message}
</div>
Call announce("Loaded 200 results. Showing item 1 of 200.") after async data resolves. For the choice between aria-live="polite" and aria-live="assertive", see choosing between polite and assertive aria-live regions.
Gotchas
Permalink to "Gotchas"1. aria-setsize becomes stale after filtering
When the user applies a filter that reduces items.length, every rendered row still carries the old aria-setsize value until React re-renders. If the new dataset arrives asynchronously, there is a window where the count is wrong. Announce the new count via the live region immediately after the filter settles, before React has re-rendered all rows, so the screen reader hears the correct total even if it reads a stale row.
2. Variable-height recycling breaks scroll position after content expansion
VariableSizeList caches row measurements. When a row’s content expands (e.g. an accordion opens inside the row), the cached height is wrong and react-window miscalculates all subsequent scroll offsets. Fix:
const measureRef = useCallback((node) => {
if (!node) return;
const observer = new ResizeObserver(() => {
// Invalidate all measurements from index 0 onward; false = do not force re-render
listRef.current?.resetAfterIndex(0, false);
});
observer.observe(node);
return () => observer.disconnect();
}, []);
Attach ref={measureRef} to the inner wrapper of each row. Note that resetAfterIndex with shouldForceUpdate=false avoids an infinite loop when the resize is triggered by the measurement itself.
3. JAWS in virtual cursor mode ignores role="listbox" keyboard shortcuts
JAWS switches between virtual (reading) mode and application (interaction) mode. In virtual mode, arrow keys move the reading cursor, not the widget cursor. The role="listbox" attribute should trigger automatic mode switching in JAWS 2023+, but older versions do not. Provide a visible instruction — “Use arrow keys to navigate items” — within the component or as a tooltip, satisfying WCAG 3.3.2 (Labels or Instructions).
FAQ
Permalink to "FAQ"Why does NVDA announce "list end" immediately when my react-window list loads?
NVDA builds its virtual buffer from the DOM at load time. react-window renders only viewport rows, so NVDA sees a list with 10–20 items and no continuation. Fix it by adding aria-setsize={totalCount} to every row — NVDA reads this attribute instead of counting physical DOM nodes to determine list length.
Should I use aria-activedescendant or roving tabindex for react-window keyboard navigation?
Use roving tabindex when rows receive physical DOM focus (the pattern in this guide). Use aria-activedescendant only when the container holds permanent DOM focus and rows never receive it directly. Mixing both on the same widget confuses JAWS and VoiceOver, which may announce the item twice or not at all.
Does the react-window 2.x API change how ARIA injection works?
Only the component wiring changes. Version 2 replaces FixedSizeList and VariableSizeList with a unified List component using rowComponent and rowProps render props. The ARIA attributes — aria-setsize, aria-posinset, aria-selected, roving tabindex, and live region patterns — are identical in both versions.
Related
- Accessible Virtualized List Patterns — parent cluster covering the full virtualization accessibility problem space
- Implementing roving tabindex for custom data grids — the general roving-tabindex pattern this page applies
- Choosing between polite and assertive aria-live regions — when to escalate live region urgency during async data loads
- Real-time data stream announcements — scaling live region strategies to streaming data pipelines
- DOM size limits and performance tradeoffs — deciding when virtualization is necessary vs. when a full DOM render is preferable