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" or role="treegrid" — use aria-rowcount / aria-rowindex instead (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.

react-window ARIA injection overview A diagram with two columns. Left column: 5000-item dataset array. An arrow labelled "viewport slice (15 rows)" points right. Right column: three rendered row nodes labelled Row 2999, Row 3000, Row 3001, each showing aria-posinset and aria-setsize values. A badge below reads "Screen reader announces: 3000 of 5000". Full dataset items[0] items[1] items[2998] items[2999] items[3000] items[4999] (5000 items total) viewport slice Row 2999 aria-posinset="2999" aria-setsize="5000" Row 3000 (active) aria-posinset="3000" aria-setsize="5000" Row 3001 aria-posinset="3001" aria-setsize="5000" Rendered DOM (15 rows) SR announces: "3000 of 5000"

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-window 1.x API (FixedSizeList / VariableSizeList). Version 2 replaces them with a unified List component accepting rowComponent and rowProps. 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

Back to Accessible Virtualized List Patterns