Scanning Virtualized Lists Without False Negatives
A virtualised list is a deliberate lie about the DOM, and an accessibility scanner believes it completely. This guide is part of DOM Inspection for Dynamic Content, and it covers what to do when a windowed grid renders twenty of ten thousand rows: why the resulting clean report is a false negative rather than a false positive, the two strategies that actually restore coverage, how to keep a row’s accessible name correct as the library recycles its DOM nodes, and how to assert the role="grid" contract that tells assistive technology the real total.
Root Cause
Windowing libraries work by rendering only what fits. The container measures its own height, divides by the row height, renders that many rows plus a small overscan buffer, and props the scrollbar up with a spacer element whose height is the row count multiplied by the row height. Scroll down and the library recycles the same handful of DOM nodes with new data. At any instant the document contains twenty rows, or forty, or whatever the viewport allows — never ten thousand, because rendering ten thousand rows is the performance problem virtualisation exists to solve.
An accessibility scanner walks the document it is given. axe-core cannot evaluate a node that does not exist, so a scan of that page evaluates twenty rows and reports on twenty rows. The output is not marked as partial, because from axe’s perspective nothing is partial: it walked the whole document and found everything in it. The report says zero violations, and a report that says zero violations because the page is correct and a report that says zero violations because 99.8% of the rows were absent are byte-for-byte indistinguishable.
This is a false negative, and the distinction from a false positive matters more than it sounds. A false positive is a finding that is not a real defect — the scanner reports color-contrast against text nobody can see, someone triages it, and the cost is an hour and an exclusion with a comment. It is loud, and being loud is what makes it survivable. A false negative is the opposite: the pipeline manufactures evidence of safety. The unlabelled icon button on the cancelled-order row genuinely exists in the product, and a keyboard user hits it on page four of the grid, but no node representing it was ever in the DOM at scan time, so no rule could fire, no annotation appeared on the pull request, and the team’s dashboard shows a green trend line. Nothing in the toolchain will ever surface it. If a broad class of findings has to be wrong, prefer the noisy kind and spend the effort on the techniques in reducing false positives in automated accessibility scanners instead.
The reason sampling twenty rows is not good enough is variance. If every row were structurally identical, one row would prove the whole list. Real grids are not: a row renders a status badge whose markup branches on the status, an icon-only action that only appears when the order is editable, an inline error state, an optional link when a tracking number exists, an empty cell when a field is null, and a group header every time the date changes. Those are six or more structural variants, and the first window of a grid sorted by date descending typically contains one or two of them. Coverage, therefore, is not a question of how many rows the scan sees — it is a question of whether it saw one instance of each variant.
Configuration
Two strategies work. Pick per grid rather than per repository.
Strategy A — disable windowing for a bounded fixture. Give the component a test-only escape hatch and pair it with a fixture that contains one row of every variant. The pairing is essential: turning windowing off against the production dataset renders ten thousand rows, and axe’s per-node work — particularly color-contrast, which resolves computed styles and background stacks — turns a two-second scan into a several-minute one, if the tab survives at all. Forty rows covering six variants is a complete scan; ten thousand rows covering the same six variants is the same coverage at two hundred times the cost.
// src/components/OrdersGrid.tsx — the escape hatch, guarded so it cannot ship on
export function OrdersGrid({ rows }: { rows: Order[] }) {
// Only honoured in a non-production bundle: the flag is dead code in prod.
const fullRender =
process.env.NODE_ENV !== 'production' &&
new URLSearchParams(window.location.search).get('a11y') === 'full-render';
// Rendering every row is only safe for a bounded fixture; refuse otherwise so
// nobody points this at the real dataset and blames the scanner for timing out.
if (fullRender && rows.length > 200) {
throw new Error(`a11y=full-render supports up to 200 rows, got ${rows.length}`);
}
return fullRender ? <PlainGrid rows={rows} /> : <WindowedGrid rows={rows} />;
}
// tests/a11y/orders-grid-full.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
// One row per structural variant, seeded by the fixture endpoint.
const VARIANTS = [
'paid', 'refunded', 'cancelled', 'pending',
'error-row', 'group-header', 'null-tracking', 'editable',
];
test('every row variant is scanned with windowing disabled', async ({ page }) => {
await page.goto(`/orders?fixture=variant-sample&a11y=full-render`);
const grid = page.getByRole('grid', { name: 'Orders' });
await grid.waitFor();
// Coverage assertion first: a scan of the wrong DOM is worse than no scan.
const present = await page.locator('[data-row-variant]').evaluateAll((rows) =>
[...new Set(rows.map((r) => r.getAttribute('data-row-variant')))].sort(),
);
expect(present, 'a variant is missing from the fixture').toEqual([...VARIANTS].sort());
const results = await new AxeBuilder({ page })
.include('[data-grid-body]') // the grid only; the page shell has its own spec
.analyze();
expect(results.violations.map((v) => `${v.id} x${v.nodes.length}`)).toEqual([]);
});
Strategy B — scroll through the windows and merge. When the component cannot be modified, or when the grid’s own scroll behaviour is part of what needs testing, scan each window and combine the results. Two details make the difference between a working loop and a flaky one. First, dedupe findings on the rule id plus the row variant, never on the CSS selector axe returns: selectors are position-based, so the same defect in two windows produces two different selectors and the merged count inflates. Second, guard the loop on progress — if the first rendered row index does not advance, break rather than spin, because a grid with variable row heights can stall a fixed-pixel scroll.
// tests/a11y/orders-grid-windows.spec.ts
import { test, expect, type Page } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
async function readWindowState(page: Page) {
return page.evaluate(() => {
const body = document.querySelector<HTMLElement>('[data-grid-body]')!;
const rows = [...body.querySelectorAll<HTMLElement>('[aria-rowindex]')];
return {
scrollTop: body.scrollTop,
clientHeight: body.clientHeight,
scrollHeight: body.scrollHeight,
firstIndex: Number(rows[0]?.getAttribute('aria-rowindex') ?? -1),
lastIndex: Number(rows.at(-1)?.getAttribute('aria-rowindex') ?? -1),
};
});
}
test('scanning every window of the orders grid', async ({ page }, info) => {
await page.goto('/orders?fixture=ten-thousand');
await page.getByRole('grid', { name: 'Orders' }).waitFor();
const findings = new Map<string, { rule: string; variant: string; help: string }>();
const windowsScanned: number[] = [];
let previousFirst = -1;
for (let pass = 0; pass < 60; pass++) {
// The rows for this window must be committed before the scan runs.
await expect
.poll(() => page.locator('[data-grid-body] [aria-rowindex]').count())
.toBeGreaterThan(0);
const results = await new AxeBuilder({ page }).include('[data-grid-body]').analyze();
for (const violation of results.violations) {
for (const node of violation.nodes) {
const variant = /data-row-variant="([^"]+)"/.exec(node.html)?.[1] ?? 'unknown';
// Key on rule + variant: the selector differs per window for one defect.
findings.set(`${violation.id}::${variant}`, {
rule: violation.id,
variant,
help: violation.help,
});
}
}
const state = await readWindowState(page);
windowsScanned.push(state.firstIndex);
if (state.firstIndex === previousFirst) {
throw new Error(`scroll stalled at row ${state.firstIndex}; row heights vary`);
}
previousFirst = state.firstIndex;
if (state.scrollTop + state.clientHeight >= state.scrollHeight - 2) break;
// Overlap by two rows so a partially rendered boundary row is never skipped.
await page.locator('[data-grid-body]').evaluate((el, overlap: number) => {
el.scrollTop += el.clientHeight - overlap;
}, 96);
}
await info.attach('windows.json', {
body: JSON.stringify({ windowsScanned, findings: [...findings.values()] }, null, 2),
contentType: 'application/json',
});
expect([...findings.values()]).toEqual([]);
});
Recycling introduces a defect that only strategy B can find. When the library reuses a row’s DOM node for a different record, every piece of that row has to be rewritten — including the parts that are not visible text. An aria-label="Open order 10482" on an icon-only action, a headers reference, an aria-describedby pointing at a status cell, or a title on a truncated cell will all survive a re-render if the component computes them once from initial props. The row then displays order 10930 and announces order 10482. Assert the relationship rather than the value:
// tests/a11y/row-identity.spec.ts — accessible names must follow the data
import { test, expect } from '@playwright/test';
test('row action names still match the row after recycling', async ({ page }) => {
await page.goto('/orders?fixture=ten-thousand');
const body = page.locator('[data-grid-body]');
await body.waitFor();
for (const jump of [0, 4000, 12000, 400]) {
await body.evaluate((el, top: number) => { el.scrollTop = top; }, jump);
await expect.poll(() => body.locator('[role="row"]').count()).toBeGreaterThan(1);
const mismatches = await body.evaluate(() =>
[...document.querySelectorAll<HTMLElement>('[role="row"][data-order-id]')]
.filter((row) => {
const id = row.dataset.orderId!;
const action = row.querySelector('[data-row-action]');
const name = action?.getAttribute('aria-label') ?? '';
// The label must name the record the row is currently showing.
return !name.includes(id);
})
.map((row) => row.dataset.orderId),
);
expect(mismatches, `stale accessible names after scrolling to ${jump}`).toEqual([]);
}
});
The last piece is the container contract, and it is the part that helps users rather than tests. A role="grid" whose DOM holds twenty rows tells assistive technology that the grid has twenty rows, so a screen reader announces “row 3 of 20” in a ten-thousand-row list and the user has no idea how much data exists or where they are in it. aria-rowcount on the grid declares the true total, and aria-rowindex on each rendered row declares its real position, counting the header row as index 1. When the total genuinely is not known — an endless feed — aria-rowcount="-1" is the correct declaration. Assert both against the API’s own count so the numbers cannot drift:
// tests/a11y/grid-contract.spec.ts
import { test, expect } from '@playwright/test';
test('grid declares the real row total and real row positions', async ({ page }) => {
let total = 0;
// Read the truth from the response rather than trusting the fixture name.
page.on('response', async (res) => {
if (res.url().includes('/api/orders')) total = (await res.json()).totalCount;
});
await page.goto('/orders?fixture=ten-thousand');
const grid = page.getByRole('grid', { name: 'Orders' });
await grid.waitFor();
// +1 for the header row, which is part of the grid's row count.
await expect(grid).toHaveAttribute('aria-rowcount', String(total + 1));
const indexes = await grid
.locator('[role="row"][aria-rowindex]')
.evaluateAll((rows) => rows.map((r) => Number(r.getAttribute('aria-rowindex'))));
expect(indexes.length, 'no rows carry aria-rowindex').toBeGreaterThan(1);
// Monotonic, 1-based, and inside the declared total.
expect([...indexes].sort((a, b) => a - b)).toEqual(indexes);
expect(Math.min(...indexes)).toBeGreaterThanOrEqual(1);
expect(Math.max(...indexes)).toBeLessThanOrEqual(total + 1);
});
Validation
Break each guarantee separately and confirm the right test objects:
npx playwright test tests/a11y/orders-grid-full.spec.ts tests/a11y/grid-contract.spec.ts \
tests/a11y/row-identity.spec.ts --reporter=list
# Fixture regenerated without the cancelled-order variant:
# ✘ every row variant is scanned with windowing disabled
# a variant is missing from the fixture
# Expected: [..."cancelled"...] Received: [..."paid","pending","refunded"...]
#
# Row action label computed once from initial props:
# ✘ row action names still match the row after recycling
# stale accessible names after scrolling to 12000
# Expected: [] Received: ["10930","10931","10932"]
#
# aria-rowcount left at the rendered row count:
# ✘ grid declares the real row total and real row positions
# expect(locator).toHaveAttribute("aria-rowcount", "10001")
# Received: "21"
#
# All three correct:
# ✓ every row variant is scanned with windowing disabled
# ✓ row action names still match the row after recycling
# ✓ grid declares the real row total and real row positions
One more sanity check is worth building into the full-render spec: assert on the volume of rules that fired, not only on the violations. results.passes carries a nodes array per rule, so a scan that suddenly evaluates 40 nodes for button-name instead of 320 has silently lost its coverage — usually because the escape-hatch flag stopped being honoured after a refactor. A green scan with a tenth of the node count is the exact failure this whole guide exists to prevent, and it costs one assertion to notice.
Edge Cases and Conditional Guards
- Spacer elements inside
role="grid". Most windowing libraries insert sizing<div>elements as direct children of the scroll container, which puts non-row children inside a grid and tripsaria-required-children. Give the spacersrole="presentation"andaria-hidden="true"so they leave the accessibility tree entirely; that fixes the real defect rather than muting the rule, and it stops the spacer from being counted as content by anything else. - Horizontally virtualised columns. Grids that window columns as well as rows need
aria-colcountand per-cellaria-colindex, and they need a header cell present for every rendered column — a scrolled-away header leaves the visible cells with no announced column name at all. Add the column dimension to the scroll loop, and if the grid also carries multi-level headers, the resolution rules are the subject of writing custom axe-core rules for complex data tables. - Variable row heights and sticky group headers. A fixed-pixel scroll step assumes uniform rows; with variable heights it can overshoot a short window entirely, and a sticky group header can cover the first row of the next window so it is rendered but not laid out where a geometry-sensitive rule expects. The progress guard in the loop catches the stall case, and an overlap of two row heights per step covers the overshoot; for grids with a sticky header, also assert that the first rendered row is not obscured before trusting a
target-sizeresult.
Pipeline Impact
The two strategies belong in different jobs because their costs differ by an order of magnitude. Strategy A is one page load and one scan, so it runs on every pull request alongside the rest of the accessibility suite. Strategy B multiplies runtime by the number of windows — seven scans of a moderately sized grid is roughly 15 seconds, and a hundred-window feed is several minutes — so it belongs in a nightly job, with the merged findings routed to the same reporting path as the rest of the suite rather than to a blocking gate.
Merged results interact badly with count-based budgets if the deduplication is wrong. Scanning seven windows of a grid with one broken variant yields up to seven copies of one finding, and a threshold or baseline keyed on violation counts will read that as seven regressions, ratchet the budget up to accommodate them, and then permanently tolerate six real defects. Deduplicate before comparing against any budget, and keep the merged artifact separate from the per-window raw output so the numbers a gate sees are the numbers a human would agree with — the ratcheting mechanics themselves are covered in setting up progressive accessibility thresholds in CI.
Coverage assertions should fail the build as loudly as violations do. A missing variant, a node count that dropped by an order of magnitude, or a scroll loop that exited after one pass are all the same class of problem: the scan did not look at the thing it claims to have approved. Failing on those keeps the green result meaningful, which is the only property that makes a gate worth having.
Common Pitfalls
- Reading a zero-violation report from a windowed grid as evidence of anything, when it is primarily evidence that twenty rows existed.
- Turning windowing off against the production dataset, which converts a coverage problem into a timeout and gets the whole approach abandoned.
- Deduplicating merged findings on axe’s CSS selector, which is position-based and therefore unique per window, inflating one defect into one per pass.
- Sampling only the first window because the rows “are all the same”, when the branching that creates variants is exactly what a scanner needs to see.
- Leaving
aria-rowcountunset so assistive technology announces a ten-thousand-row grid as twenty rows, and the user cannot tell how much data is below them. - Computing a row’s
aria-labeloraria-describedbyonce from initial props, so recycled rows announce the record they used to show. - Scrolling with
scrollIntoView()on the last rendered row, which jumps by an unpredictable amount and can skip a whole window between scans.
FAQ
Is testing the row component in isolation enough? It is the cheapest and most complete way to cover variants, and it is not sufficient on its own. A component test can render all six variants in milliseconds and assert each one, which no page-level scan can match for cost, and it belongs in the suite — the mechanics for driving that from a component runner are in configuring cypress-axe for component testing. What it cannot prove is anything about the container: the row count declared to assistive technology, the index continuity across windows, the spacer children inside the grid role, or the stale names produced by recycling. Those defects live in the composition, so they need the grid in a page.
How many windows are enough if scanning all of them is too slow?
Sample by variant rather than by position. If the fixture is ordered so that every structural variant appears in the first, middle and last window, three windows give better coverage than twenty consecutive ones from the top, and the loop can jump scrollTop directly to those offsets. Where the data cannot be ordered, scan the first and last window plus a deterministic pseudo-random selection seeded by the commit — that way each run covers different ground while the failure is still reproducible from the seed recorded in the artifact.
Does axe-core have an option that handles virtualised content?
No, and it cannot have one. include and exclude narrow the set of nodes axe walks; neither can create a node that the application never rendered, and there is no API for asking a framework to materialise off-screen content. This is a property of the page, not a configuration gap, which is why the fix always lives in the test — either changing what the application renders, or driving the application until it has rendered the rest.
Related
- DOM Inspection for Dynamic Content — the parent guide on settle signals, interaction states and deliberately incomplete DOM.
- Handling Dynamic ARIA States in Modern JavaScript Frameworks — the sibling guide on state attributes that go stale when a component re-renders.
- Accessibility Testing Fundamentals & Tool Selection — the section covering scanner configuration, run options and where scans belong in a suite.