JavaScript Accessibility Testing 2026: Tools and Techniques for SPAs and Dynamic Content
Modern JavaScript apps — React, Vue, Next.js, Svelte SPAs — present unique accessibility testing challenges that traditional page-crawl tools miss. Dynamic content, client-side routing, async state, and complex interactive components all create accessibility issues that only surface in specific application states. This guide covers the tools and techniques that actually work for JS-heavy applications.
Testing Stack Summary
Why JavaScript Apps Are Harder to Test for Accessibility
Traditional accessibility testing tools work by crawling URLs and analyzing HTML. This approach misses the majority of accessibility issues in modern JavaScript apps:
Content loaded after page render
A page crawler sees the initial HTML shell. In a React SPA, the actual content renders after JavaScript executes and API calls complete. Violations in dynamically rendered content are invisible to static-crawl tools.
Application state
An accessibility issue might only appear when a modal is open, when a form has an error, when a user is logged in, or when a specific filter is applied. Page crawlers visit URLs, not application states.
Client-side routing
When a user navigates between routes in a SPA, the browser doesn't do a full page reload. Focus may not move to an appropriate element. The new page title may not be announced to screen readers. These are legitimate WCAG violations that page crawlers won't detect.
Custom interactive components
Custom dropdowns, date pickers, comboboxes, carousels, and accordions built with div elements instead of native HTML controls require careful ARIA implementation. Violations in these components only surface when you test them in the state they're actually used.
Focus management
When a modal closes, focus should return to the element that opened it. When a SPA route changes, focus should move to a meaningful location. When an async operation completes, status should be announced to screen readers. These require testing user interaction flows, not just HTML snapshots.
Layer 1: Unit Tests with jest-axe
jest-axe integrates axe-core's accessibility engine into your Jest test suite. It's the fastest way to add accessibility coverage at the component level — violations are caught before code reaches a browser.
Installation
npm install --save-dev jest-axe @testing-library/react @testing-library/jest-dom
Basic Usage (React)
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { MyButton } from './MyButton';
expect.extend(toHaveNoViolations);
it('has no accessibility violations', async () => {
const { container } = render(<MyButton onClick={() => {}}>Submit</MyButton>);
const results = await axe(container);
expect(results).toHaveNoViolations();
});Testing Components in Different States
Test each meaningful state: empty, with data, with errors, with loading. Many violations only appear in specific states.
it('form with validation errors has no violations', async () => {
const { container } = render(
<ContactForm
errors={{ email: 'Please enter a valid email address' }}
values={{ email: 'bad-email' }}
/>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('loading state has no violations', async () => {
const { container } = render(<DataTable loading={true} rows={[]} />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});jest-axe limitations
- Runs in jsdom (Node.js), not a real browser — color contrast checks are skipped because jsdom can't compute computed styles
- Can't test keyboard navigation flows (e.g., tab order, focus trapping in modals)
- Can't test screen reader announcements for dynamic content
- Great for catching structural ARIA violations, missing labels, invalid HTML attributes
Layer 2: End-to-End Tests with cypress-axe
Cypress runs in a real browser, which means it can test accessibility after JavaScript executes, after user interactions, and after async content loads — catching the violations that jest-axe can't reach.
Installation
npm install --save-dev cypress-axe axe-core
Setup (cypress/support/e2e.js)
import 'cypress-axe';
Testing a User Flow
describe('Checkout flow accessibility', () => {
beforeEach(() => {
cy.visit('/checkout');
cy.injectAxe();
});
it('has no violations on load', () => {
cy.checkA11y();
});
it('has no violations after filling in the form', () => {
cy.get('#email').type('test@example.com');
cy.get('#card-number').type('4111111111111111');
cy.checkA11y();
});
it('has no violations when error messages appear', () => {
cy.get('[data-cy=submit]').click();
// Wait for validation errors to render
cy.get('[role=alert]').should('be.visible');
cy.checkA11y();
});
});Testing Modal Focus Management
it('modal has no violations and manages focus correctly', () => {
cy.get('[data-cy=open-modal-btn]').click();
cy.get('[role=dialog]').should('be.visible');
cy.injectAxe();
cy.checkA11y('[role=dialog]'); // scope axe to just the modal
// Verify focus moved into the modal
cy.focused().should('be.within', '[role=dialog]');
// Close and verify focus returns
cy.get('[aria-label="Close"]').click();
cy.focused().should('have.attr', 'data-cy', 'open-modal-btn');
});Layer 2 (Alternative): Playwright Accessibility Testing
Playwright is increasingly preferred over Cypress for accessibility E2E testing, particularly because it ships with built-in accessibility snapshot testing and native axe-core integration via @axe-core/playwright.
Installation
npm install --save-dev @axe-core/playwright
Usage
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('homepage has no critical accessibility violations', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.analyze();
expect(results.violations).toEqual([]);
});
test('dashboard after login has no violations', async ({ page }) => {
await page.goto('/login');
await page.fill('#email', 'user@example.com');
await page.fill('#password', 'password');
await page.click('[type=submit]');
await page.waitForURL('/dashboard');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.analyze();
expect(results.violations).toEqual([]);
});Playwright also provides page.accessibility.snapshot() for ARIA tree snapshots — useful for verifying how screen readers will interpret complex component structures.
Layer 3: Static Analysis with eslint-plugin-jsx-a11y
eslint-plugin-jsx-a11y catches common accessibility anti-patterns at write time — before tests run, before code is committed. It's free and integrates with every major editor and CI pipeline.
Installation
npm install --save-dev eslint-plugin-jsx-a11y
.eslintrc.js (recommended config)
module.exports = {
plugins: ['jsx-a11y'],
extends: ['plugin:jsx-a11y/recommended'],
rules: {
// Upgrade these from 'warn' to 'error' in the recommended config:
'jsx-a11y/alt-text': 'error',
'jsx-a11y/label-has-associated-control': 'error',
'jsx-a11y/no-redundant-roles': 'error',
'jsx-a11y/interactive-supports-focus': 'error',
},
};What eslint-plugin-jsx-a11y catches
<img>without alt attribute or with empty alt on non-decorative images<button>without accessible name<a>with no href and no role, or with empty content- Interactive elements (
onClick) on non-interactive elements without role and tabIndex - Form elements without associated labels
- Heading elements used out of order (h1 → h3, skipping h2)
- Mouse-only event handlers without keyboard equivalents
CI/CD Integration: Block Accessibility Regressions
The most valuable thing you can do with these tools is integrate them into your CI pipeline so accessibility regressions can't merge to main.
GitHub Actions Example
name: Accessibility CI
on: [push, pull_request]
jobs:
accessibility:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
# Layer 1: ESLint jsx-a11y
- name: Lint accessibility rules
run: npm run lint
# Layer 2: jest-axe unit tests
- name: Run accessibility unit tests
run: npm test -- --testPathPattern=accessibility
# Layer 3: Playwright E2E accessibility
- name: Install Playwright
run: npx playwright install --with-deps chromium
- name: Start app
run: npm run build && npm run start &
- name: Wait for app
run: npx wait-on http://localhost:3000
- name: Run Playwright accessibility tests
run: npx playwright test accessibility.spec.tsFramework-Specific Notes
React
- Use React Testing Library (not Enzyme) with jest-axe — RTL renders closer to how browsers see your components
- Custom hooks that manage focus (e.g., on modal open/close) need integration tests, not unit tests
- For portals (modals rendered outside the React tree), make sure jest-axe receives the correct container element
Next.js
- App Router components can be server or client — test client components with jest-axe; test server-rendered output via Playwright
- Route changes in Next.js App Router should announce new page titles — test this manually with VoiceOver or NVDA, or with Playwright screen reader emulation
- Image component: always pass meaningful alt text; Next.js won't warn on missing alt if you use the Image component without it
- Use
next/link— it handles focus management on route changes better than raw anchor tags
Vue 3 / Nuxt
- Use
@vue/test-utilswith jest-axe — same pattern as React Testing Library - eslint-plugin-vuejs-accessibility is the Vue equivalent of jsx-a11y
- Vuetify and other component libraries have varying WCAG coverage — test your actual rendered output, don't assume library components are accessible
What Automated Tests Won't Catch: Manual Testing Checklist
Automated tools catch about 30–40% of WCAG issues. For the rest, add these manual checks before every release:
Monitor your production site
Even with unit tests and E2E tests, production deployments introduce regressions — content changes, third-party script updates, A/B test variations. RatedWithAI continuously monitors your live site for WCAG violations, alerting you when new issues appear.
Start monitoring your site →Frequently Asked Questions
Why is accessibility testing harder for JavaScript SPAs?
Traditional accessibility testing tools scan static HTML. Single-page applications (SPAs) build and modify the DOM dynamically using JavaScript — content loads after the initial page render, components mount and unmount based on user interaction, routes change without full page reloads. Many accessibility violations only appear in specific application states that automated page crawlers never reach. Testing SPAs requires running accessibility checks after user interactions, after async data loads, and in each application state.
What is jest-axe and how do I use it?
jest-axe is an npm package that integrates the axe-core accessibility engine into Jest unit tests. After rendering a React (or any JS framework) component with a testing library like React Testing Library, you pass the rendered output to jest-axe's toHaveNoViolations() matcher. It checks the rendered HTML against axe-core's WCAG rules and fails the test if violations are found. It's ideal for component-level accessibility testing: catch violations before they even reach a browser.
What's the difference between jest-axe and cypress-axe?
jest-axe runs at the unit test level — it tests component HTML rendered in Node.js (via jsdom). It catches structural accessibility violations in component markup: missing labels, invalid ARIA attributes, bad heading hierarchy. Cypress-axe runs in a real browser as part of end-to-end tests — it can test complex interaction flows, dynamic content loaded via APIs, modals, and focus management after user actions. Both tools use axe-core under the hood. Best practice: use both — jest-axe for components, cypress-axe for critical user flows.
Can automated tools catch all WCAG violations in a JavaScript app?
No. Automated tools using axe-core (the industry standard engine) catch about 30–40% of WCAG 2.1 violations — including missing alt text, missing form labels, color contrast failures, and invalid ARIA usage. Issues that require human judgment include: whether dynamic focus management after modal close is intuitive, whether loading spinners are announced appropriately to screen readers, whether error messages clearly describe the error, and whether complex widgets like date pickers work correctly with NVDA or VoiceOver. Automated testing is necessary but not sufficient.
How do I test accessibility for client-side routing in React?
Client-side routing in React (React Router, Next.js App Router) often fails to announce page changes to screen readers — the browser doesn't trigger the normal focus management and page title updates that occur on full page loads. To test this: use cypress-axe after navigation events to check the new page state; manually test with NVDA and VoiceOver to verify that page title changes are announced; check that focus is moved to an appropriate element (like the main heading or a skip link target) after route changes; use the aria-live region or document.title update patterns recommended by framework maintainers.
What's the best accessibility testing setup for a Next.js project?
A layered approach works best: (1) ESLint with eslint-plugin-jsx-a11y for static analysis at write time; (2) jest-axe in component tests with React Testing Library; (3) Playwright with @axe-core/playwright for E2E tests covering critical user flows; (4) RatedWithAI or axe DevTools for continuous monitoring of the deployed site. Configure your GitHub Actions workflow to run jest-axe and Playwright accessibility checks on every PR so violations are caught before merge.