RatedWithAI

RatedWithAI

Accessibility scanner

JavaScript Accessibility Testing 2026: Tools and Techniques for SPAs and Dynamic Content

Updated June 2026·15 min read·Developer Guide

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

Unit leveljest-axe + React Testing Library — catch violations in component output before they reach a browser
E2E levelcypress-axe or @axe-core/playwright — test real browser flows, interactions, and async-loaded content
Static analysiseslint-plugin-jsx-a11y — surface common patterns at write time in your editor
Deployed siteRatedWithAI, axe DevTools, or Siteimprove — continuous monitoring of production for regressions

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.ts

Framework-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-utils with 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:

Keyboard navigation only: Tab through every interactive element on every page. Can you reach everything? Is focus order logical? Are focus indicators visible?
Screen reader with NVDA (Windows) or VoiceOver (Mac/iOS): Test critical user flows end-to-end. Can a screen reader user complete checkout, sign up, and navigate primary content?
200% zoom: Zoom the browser to 200%. Does content reflow or does it require horizontal scrolling? Do interactive elements remain reachable?
Color contrast: Use a contrast checker on primary text, UI controls, and status indicators. WCAG AA requires 4.5:1 for normal text, 3:1 for large text.
Form error messages: Submit every form with intentional errors. Are error messages programmatically associated with the failing field? Are they announced by screen readers?
Modal focus trapping: Open every modal. Verify focus moves into it. Verify Tab cycles within the modal and doesn't escape. Verify Escape closes it and focus returns.
Live regions and status updates: Trigger loading states, success toasts, and error toasts. Are they announced by screen readers via aria-live or role=status/alert?

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.