Accessible Virtualized List Patterns

Permalink to "Accessible Virtualized List Patterns"

Virtualization solves DOM size limits by rendering only the rows visible in the viewport, recycling nodes as the user scrolls. For sighted users this is invisible; for screen reader users it silently destroys positional context — the AT cannot tell whether a list has 20 items or 200,000. This page explains how to synchronise the accessibility tree with recycled DOM nodes so that aria-posinset, aria-setsize, keyboard focus order, and live announcements all remain accurate regardless of scroll position.

The patterns here primarily affect blind users navigating with NVDA, JAWS, and VoiceOver, but they also matter for keyboard-only users who rely on Home/End to jump to list boundaries and for users with motor disabilities who cannot fine-scroll to reach off-screen items.


WCAG Criteria in Scope

Permalink to "WCAG Criteria in Scope"
Criterion Level Relevance to this pattern
1.3.1 Info and Relationships A Positional information (aria-posinset, aria-setsize) must be programmatically determinable
2.1.1 Keyboard A All list items must be reachable and operable by keyboard alone
2.4.3 Focus Order A Focus sequence must reflect the logical order of the virtual dataset, not the recycled DOM order
2.4.7 Focus Visible AA The focused item must always be visible in the viewport after keyboard navigation scrolls it into view
4.1.2 Name, Role, Value A Every list item must have a correct role, computed name, and up-to-date ARIA state
4.1.3 Status Messages AA Dynamic count or filter-result changes must be announced without moving focus

Prerequisites

Permalink to "Prerequisites"

Before implementing virtualized list accessibility, you should understand:


Architecture: How DOM Recycling Breaks the Accessibility Tree

Permalink to "Architecture: How DOM Recycling Breaks the Accessibility Tree"

The diagram below shows what happens at the accessibility tree level when a user scrolls a naively implemented virtualized list. Items 1–5 exist in the AT tree at first paint. As the user scrolls, nodes are recycled and their content replaced — but without aria-posinset updates the AT still reports “item 1 of 5” even when the content is item 47.

DOM recycling breaks screen reader positional context without ARIA synchronisation A three-column sequence diagram showing the scroll container, the DOM, and the accessibility tree. At initial paint, five nodes are created with posinset 1–5. After scroll, the DOM recycles those nodes and injects items 46–50, but the accessibility tree still reports posinset 1–5 because aria-posinset was not updated. The fix column shows the same scroll event triggering an aria-posinset update before the DOM content is replaced. Scroll Container DOM Nodes AT Tree 1. Initial paint 5 nodes rendered posinset 1–5 Announces "1 of 5" through "5 of 5" ✓ 2. User scrolls (naive: no ARIA update) Nodes recycled; content replaced with items 46–50 Still announces "1 of 5" through "5 of 5" ✗ stale 3. User scrolls (fixed: update ARIA before paint) aria-posinset set to 46–50 before textContent is replaced Announces "46 of 1 420" through "50 of 1 420" ✓ Rule: aria-posinset and aria-setsize must be updated synchronously before textContent changes. Use requestAnimationFrame only for DOM focus; never defer ARIA attribute writes.

ARIA & HTML Spec Reference

Permalink to "ARIA & HTML Spec Reference"

aria-setsize

Permalink to "aria-setsize"
  • Valid values: integer ≥ 1, or -1 when the total count is genuinely unknown
  • When to apply: on every role="listitem", role="option", role="treeitem", role="row", or role="gridcell" inside a virtualized container — or on the container itself when using role="listbox" or role="grid"
  • Common misuse: setting aria-setsize to the number of currently rendered nodes (e.g. 20) instead of the full dataset length (e.g. 1 420). This makes the screen reader announce “20 items” when the dataset is far larger.

aria-posinset

Permalink to "aria-posinset"
  • Valid values: integer ≥ 1, must be ≤ the aria-setsize value (or ≤ total when setsize is on the container)
  • When to apply: on each recycled listitem/option/row/treeitem element, updated synchronously before the node’s text content changes
  • Common misuse: updating aria-posinset inside a requestAnimationFrame callback — by then the AT may have already read the node with the old value

role="list" + role="listitem"

Permalink to "role="list" + role="listitem""

Use for flat, unordered datasets. The container must carry aria-label or be labelled by a visible heading via aria-labelledby. Do not wrap the container in a <ul> — the role="list" on the <div> is sufficient and avoids the Safari/VoiceOver issue where list-style: none strips list semantics from <ul>.

role="grid" / role="table"

Permalink to "role="grid" / role="table""

Use for tabular data. Columns must carry role="columnheader" with correct aria-sort values. See sortable and filterable data grids for the full sort announcement pattern.

aria-rowcount / aria-rowindex

Permalink to "aria-rowcount / aria-rowindex"

The grid/table equivalents of aria-setsize/aria-posinset. aria-rowcount goes on the role="grid" container; aria-rowindex goes on each role="row". Both suffer the same recycling bug if not updated synchronously.


Step-by-Step Implementation

Permalink to "Step-by-Step Implementation"

Step 1 — Choose the correct role and container markup (WCAG 1.3.1)

Permalink to "Step 1 — Choose the correct role and container markup (WCAG 1.3.1)"
<!-- Flat list: role="list" on the scroll container -->
<!-- aria-label satisfies SC 4.1.2 Name, Role, Value -->
<div
  role="list"
  aria-label="Search results"
  class="virtual-scroll-container"
>
  <!-- Rendered items injected here by the virtualizer -->
</div>

For tabular data substitute role="grid" and add aria-rowcount in place of aria-setsize on the container.

Step 2 — Declare total count and per-item position (WCAG 1.3.1, 4.1.2)

Permalink to "Step 2 — Declare total count and per-item position (WCAG 1.3.1, 4.1.2)"
<!-- Each recycled node must carry both attributes -->
<!-- aria-setsize = full dataset length, NOT rendered slice length -->
<!-- aria-posinset = 1-based absolute index in the full dataset -->
<div
  role="listitem"
  aria-setsize="1420"
  aria-posinset="47"
  tabindex="-1"
>
  Result 47 of 1 420
</div>

Update aria-posinset and aria-setsize synchronously (before textContent is replaced) inside the virtualizer’s scroll handler — never inside a requestAnimationFrame callback.

Step 3 — Implement roving tabindex keyboard navigation (WCAG 2.1.1, 2.4.3)

Permalink to "Step 3 — Implement roving tabindex keyboard navigation (WCAG 2.1.1, 2.4.3)"
// One item at a time carries tabindex="0"; all others have tabindex="-1"
// This is the roving tabindex pattern (SC 2.1.1 Keyboard)
function handleKeyDown(event, state) {
  const { key } = event;
  const { virtualIndex, totalItems, scrollToIndex, getRenderedNode } = state;
  let nextIndex = virtualIndex;

  switch (key) {
    case 'ArrowDown': nextIndex = Math.min(virtualIndex + 1, totalItems - 1); break;
    case 'ArrowUp':   nextIndex = Math.max(virtualIndex - 1, 0); break;
    case 'Home':      nextIndex = 0;               break; // SC 2.1.1 — boundary navigation
    case 'End':       nextIndex = totalItems - 1;  break; // SC 2.1.1 — boundary navigation
    case 'PageDown':  nextIndex = Math.min(virtualIndex + 10, totalItems - 1); break;
    case 'PageUp':    nextIndex = Math.max(virtualIndex - 10, 0); break;
    default: return; // Let Tab propagate; it exits the widget (SC 2.1.1)
  }

  event.preventDefault();
  scrollToIndex(nextIndex); // Tells the virtualizer to render the target

  // Use rAF only for the DOM focus call — ARIA attributes must already be set
  // by the virtualizer's render cycle (which runs synchronously before rAF)
  requestAnimationFrame(() => {
    // aria-posinset is 1-based; dataset index is 0-based
    const node = getRenderedNode(nextIndex + 1);
    if (!node) return;

    // Demote the previously active node
    const prev = document.querySelector('[tabindex="0"][role="listitem"]');
    if (prev) prev.setAttribute('tabindex', '-1');

    // Promote the new active node (SC 2.4.7 Focus Visible — scrollIntoView keeps it in viewport)
    node.setAttribute('tabindex', '0');
    node.focus({ preventScroll: true }); // virtualizer already scrolled
  });
}

Step 4 — Wire the container to the keyboard handler (WCAG 2.1.1)

Permalink to "Step 4 — Wire the container to the keyboard handler (WCAG 2.1.1)"
<!-- tabindex="0" on the container lets Tab land here initially -->
<!-- Arrow key events are then delegated to the child items -->
<div
  role="list"
  aria-label="Search results"
  tabindex="0"
  onkeydown="handleKeyDown(event, state)"
>
  <!-- virtualizer injects items here -->
</div>

Once an item inside the container receives focus, the container’s tabindex="0" is no longer needed for Tab entry — but keep it so Tab can re-enter the widget after the user leaves and returns.

Step 5 — Add a throttled live region for dynamic data (WCAG 4.1.3)

Permalink to "Step 5 — Add a throttled live region for dynamic data (WCAG 4.1.3)"
// Live region sits OUTSIDE the scroll container (SC 4.1.3 Status Messages)
// aria-live="polite" queues announcements without interrupting current speech
// aria-atomic="true" reads the whole message, not just the changed node
const liveRegion = document.getElementById('list-status');

function announce(text, delay = 1500) {
  // Throttle prevents flooding when filter or sort changes emit many events
  clearTimeout(announce._timer);
  announce._timer = setTimeout(() => {
    liveRegion.textContent = ''; // Force re-read even if text is the same
    requestAnimationFrame(() => { liveRegion.textContent = text; });
  }, delay);
}

// Usage: filter change reduces results from 1 420 to 38
announce('Showing 38 results for "keyboard navigation"');
<!-- Placed in the document body, outside the scroll container -->
<div
  id="list-status"
  role="status"
  aria-live="polite"
  aria-atomic="true"
  class="visually-hidden"
></div>

Keyboard Interaction Contract

Permalink to "Keyboard Interaction Contract"
Key Action Expected AT Announcement Failure Indicator
Tab Enter widget (first visit) or exit widget “Search results list, 1 420 items” then “Result 1, 1 of 1 420” No role announcement; AT reads container as a plain div
ArrowDown Move to next item; scroll into view if needed “Result N+1, N+1 of 1 420” Announcement says “1 of 20” (setsize not set to full count)
ArrowUp Move to previous item; scroll into view if needed “Result N−1, N−1 of 1 420” Focus stays at item 1 when AT says it moved
Home Jump to item 1; virtualizer scrolls to top “Result 1, 1 of 1 420” Focus moves but AT announces item 1 with old content
End Jump to last item; virtualizer scrolls to bottom “Result 1 420, 1 420 of 1 420” Long delay before focus lands (scroll not triggered before focus)
PageDown Advance ~10 items “Result N+10, N+10 of 1 420” Jump exceeds viewport; focused item is off-screen (SC 2.4.7 fail)
PageUp Retreat ~10 items “Result N−10, N−10 of 1 420” Same as PageDown failure
Enter / Space Activate the focused item Item-specific action announced via live region No announcement; action silently completes

Screen Reader Compatibility Matrix

Permalink to "Screen Reader Compatibility Matrix"
AT Browser Expected Announcement Known Deviation
NVDA 2024.x Chrome 124+ “Search results list, 1 420 items. Result 47, 47 of 1 420” NVDA may re-read the container label on every item if aria-setsize is on the container rather than each item — put aria-setsize on the items
NVDA 2024.x Firefox 125+ Same as Chrome None observed
JAWS 2024 Chrome 124+ “Search results list with 1 420 items. Result 47, 47 of 1 420” JAWS virtual cursor mode intercepts arrow keys — ensure the virtualizer is a focusable widget (has tabindex) so JAWS enters application mode
JAWS 2024 Edge 124+ Same as JAWS/Chrome None observed
VoiceOver Safari 17 (macOS) “Search results, 1 420 items. Result 47, 47 of 1 420” VoiceOver reads aria-setsize on the container (not items) correctly in Safari 17+; older Safari 16 requires aria-setsize on each item
VoiceOver Safari (iOS 17) Same as macOS Swipe navigation may skip items during rapid swipe — debounce the DOM update
TalkBack Chrome (Android) “Search results list, 1 420 items. Result 47, 47 of 1 420” TalkBack linear navigation reads all rendered items in DOM order; confirm aria-posinset order matches visual order after recycling

Edge Cases & Failure Modes

Permalink to "Edge Cases & Failure Modes"

1. aria-posinset updated inside requestAnimationFrame

Permalink to "1. aria-posinset updated inside requestAnimationFrame"

If you write aria-posinset inside an rAF callback, the AT reads the node at paint time — before rAF fires — and announces the stale index. Fix: update aria-posinset and aria-setsize synchronously in the scroll event handler, before the virtualizer swaps textContent. Only the focus() call should live in rAF.

2. JAWS application mode not engaged

Permalink to "2. JAWS application mode not engaged"

JAWS defaults to virtual cursor mode and intercepts arrow keys for document navigation. A virtualized list container without tabindex="0" never triggers JAWS’s automatic switch to application mode. Fix: add tabindex="0" (or tabindex="-1" if Tab should skip the container) and optionally role="application" — though role="list" with a tabindex is usually sufficient to trigger application mode in JAWS 2023+.

3. VoiceOver on iOS drops focus during recycling

Permalink to "3. VoiceOver on iOS drops focus during recycling"

On iOS VoiceOver, if the focused node is recycled (moved off-screen by the virtualizer) before focus is moved to a new node, VoiceOver drops focus to the document body. Fix: always move focus programmatically to the next target before the virtualizer removes the current node from the viewport. Use the scrollToIndexrAFfocus() sequence from Step 3 above.

4. Live region inside the scroll container

Permalink to "4. Live region inside the scroll container"

aria-live regions inside a scroll container are often ignored by NVDA and JAWS because the virtualizer mutates the surrounding DOM, which causes the AT to lose track of the live region. Fix: always place status live regions as a direct child of <body> or of a stable ancestor well outside the scroll container.

5. Infinite scroll with unknown total

Permalink to "5. Infinite scroll with unknown total"

When streaming data has no known ceiling, aria-setsize="-1" signals an unknown length. Screen readers announce “unknown” or omit the total. As soon as the backend returns a count, update aria-setsize on all rendered items. Never leave -1 in place once you have the real count — NVDA and JAWS both suppress positional announcements entirely when setsize is -1.


Making react-window Accessible for Screen Reader Users

Permalink to "Making react-window Accessible for Screen Reader Users"

The FixedSizeList component from react-window renders a single outer <div> and an inner <div> absolutely positioned child — neither carries semantic role or ARIA attributes by default. Override via the outerElementType and innerElementType props to inject the correct semantics without forking the library.

See the complete hook-based architecture in Making React Window Accessible for Screen Reader Users, which covers outerElementType overrides, the useVirtualFocus hook, and testing with react-testing-library.

import { FixedSizeList } from 'react-window';
import { useRef, useState, useCallback } from 'react';

// Custom outer container: carries role="list" and the accessible label (SC 4.1.2)
const ListContainer = React.forwardRef(({ children, ...rest }, ref) => (
  <div
    ref={ref}
    role="list"
    aria-label="Search results"  /* SC 4.1.2 Name */
    {...rest}
  >
    {children}
  </div>
));

// Row renderer: each recycled node gets correct posinset/setsize (SC 1.3.1)
const Row = ({ index, style, data }) => (
  <div
    style={style}
    role="listitem"                              /* SC 4.1.2 Role */
    aria-posinset={index + 1}                   /* SC 1.3.1 — absolute 1-based index */
    aria-setsize={data.totalItems}              /* SC 1.3.1 — full dataset length */
    tabIndex={data.activeIndex === index ? 0 : -1}  /* roving tabindex SC 2.1.1 */
    onKeyDown={(e) => data.onKeyDown(e, index)}
  >
    {data.items[index].label}
  </div>
);

export function AccessibleVirtualList({ items, label }) {
  const listRef = useRef();
  const [activeIndex, setActiveIndex] = useState(0);

  const onKeyDown = useCallback((e, currentIndex) => {
    // Full keyboard handler — see Step 3 above
  }, [items.length]);

  return (
    <FixedSizeList
      ref={listRef}
      outerElementType={ListContainer}   /* injects role="list" */
      itemCount={items.length}
      itemData={{ items, totalItems: items.length, activeIndex, onKeyDown }}
      itemSize={44}
      height={480}
      width="100%"
    >
      {Row}
    </FixedSizeList>
  );
}

Testing Checklist

Permalink to "Testing Checklist"

Automated

Permalink to "Automated"

Keyboard-only

Permalink to "Keyboard-only"

AT manual

Permalink to "AT manual"

Permalink to "Related"

← Back to Virtualization, Charts & Dynamic Data Displays