On this page8 sections
ReferenceBeginner6-8 min reference

Accessibility Testing

A practical reference for testing whether a product is usable by people with disabilities — keyboard-only, screen reader, low vision, and motor users. Maps the WCAG vocabulary you'll see in tickets to the things you actually check.

WCAG 2.1 Quick Reference

WCAG sets out four principles — POUR — and grades each success criterion at one of three levels.

POUR principles

PrincipleWhat it means
PerceivableInformation and UI must be presented in ways users can perceive. (Alt text, captions, sufficient contrast.)
OperableUI must be operable. (Keyboard, time limits, no seizures, navigable.)
UnderstandableInformation and operation must be understandable. (Readable, predictable, input help.)
RobustContent must be robust enough to work with current and future tools, including assistive tech. (Valid HTML, ARIA used correctly.)

Conformance levels

LevelGoalExamples of what's required
A (minimum)No critical barriersText alternatives for images, full keyboard access, no content that flashes more than 3×/second
AA (industry standard)Most products target this4.5:1 contrast for normal text (3:1 for large), text resizable to 200%, focus visible, consistent navigation, error identification
AAA (highest)Optional / best-effort7:1 contrast, sign-language interpretation for video, extended audio descriptions, no time limits

Most regulations (EAA, ADA case law, Section 508) reference WCAG AA.

The most common AA criteria you'll test

  • Alt text on every meaningful image (alt="" for decorative).
  • Every form field has a programmatic label.
  • All functionality reachable via keyboard, with a visible focus ring.
  • Text and UI components meet contrast ratios (4.5:1 / 3:1).
  • Headings used correctly (one <h1>, no skipped levels).
  • Errors identified in text and described to the user.
  • Page works at 200% browser zoom and 320 px width without loss of content.
  • Page is announced correctly by a screen reader (titles, landmarks, live regions).

Color Contrast

ElementMinimum ratio (AA)Enhanced (AAA)
Normal text (< 18 pt)4.5 : 17 : 1
Large text (≥ 18 pt or 14 pt bold)3 : 14.5 : 1
UI components and graphical objects3 : 1
Incidental / logo / decorative textexemptexempt

Tools

  • WebAIM Contrast Checker — paste fg/bg, get a pass/fail.
  • Chrome DevTools → element → Styles → click the swatch → contrast ratio shown.
  • Colour Contrast Analyser (TPGi) — desktop, eyedropper across the screen.
  • axe DevTools — flags failing pairs in scans.

Common failures

  • Light grey body text on white (#9CA3AF on #FFFFFF ≈ 2.8:1 — fails).
  • Placeholder text used as the only label (placeholders fade and often fail contrast).
  • Disabled state text indistinguishable from the disabled background.
  • Focus indicators that rely on color alone with insufficient contrast against the background.
  • Text over an image with no scrim/overlay — can pass on dark spots, fail on bright ones.

Keyboard Navigation

What to test

  • Tab moves through every interactive element in a logical order.
  • Shift-Tab reverses that order.
  • Enter activates links and submits forms.
  • Space activates buttons and toggles checkboxes.
  • Arrow keys navigate within composite widgets (tabs, radio groups, listboxes, menus).
  • Escape closes dialogs, menus, and tooltips.
  • Focus is always visible — no outline: none without a replacement.

Checklist

  • Tab through the entire page from top to bottom — does the order make sense?
  • Are there any focus traps outside of modals? (Once you Tab in, can you Tab out?)
  • When a modal opens, does focus move into it? Is focus trapped while open? Does Escape close it?
  • When the modal closes, does focus return to the element that opened it?
  • Are custom dropdowns / comboboxes / sliders navigable per the WAI-ARIA Authoring Practices?
  • Is there a skip to main content link as the first focusable element?
  • On route change in a SPA, does focus move to the new page's main content or <h1>?
<a href="#main" class="sr-only focus:not-sr-only">Skip to main content</a>

<main id="main" tabindex="-1">…</main>

The link is invisible until focused, jumps past navigation directly to the main content.

Common failures

  • Click handler on a <div> with no tabindex and no role — invisible to keyboard users.
  • Custom dropdown that opens on click but ignores Arrow keys.
  • Focus disappears when an item is removed from the DOM.
  • Modal traps focus but Escape doesn't close it.
  • Focus ring removed (outline: none) without a replacement style.

ARIA Roles & Attributes

First rule of ARIA: don't use ARIA if a native HTML element does the job. <button> is always better than <div role="button">.

Landmark roles

Used to declare page regions. Prefer the semantic HTML elements — they have the role baked in.

HTML elementImplicit roleWhen to use ARIA
<header> (top-level)bannerDon't — use the element
<nav>navigationMultiple navs? Add aria-label to distinguish
<main>mainAlways — there should be exactly one
<aside>complementarySidebars
<footer> (top-level)contentinfoDon't — use the element
<section> with aria-labelledbyregionWhen you need a programmatic region

Names and descriptions

<!-- aria-label: provides the accessible name when there's no visible text -->
<button aria-label="Close dialog">×</button>
 
<!-- aria-labelledby: name comes from another element -->
<div role="dialog" aria-labelledby="dialog-title">
  <h2 id="dialog-title">Confirm delete</h2>
</div>
 
<!-- aria-describedby: secondary description -->
<input id="email" aria-describedby="email-help">
<p id="email-help">We'll never share your email.</p>

Hiding from assistive tech

<!-- aria-hidden: hide decorative content from screen readers -->
<svg aria-hidden="true" focusable="false">…</svg>
 
<!-- Don't put aria-hidden on focusable elements -->
<button aria-hidden="true">Help</button>   <!-- ✗ keyboard users still focus this -->

State

<button aria-expanded="true">Menu</button>
<button aria-pressed="false">Bold</button>
<input type="checkbox" role="checkbox" aria-checked="mixed">
<li role="option" aria-selected="true">Item 1</li>
<input aria-invalid="true" aria-describedby="email-error">

Live regions

Announce dynamic changes without moving focus.

<!-- polite: announce when the user is idle -->
<div role="status" aria-live="polite">3 results updated</div>
 
<!-- assertive: interrupt — use sparingly, for things like errors -->
<div role="alert">Session expiring in 1 minute</div>

The element must exist in the DOM before the change — adding text to a freshly-created live region often won't announce.

<div
  role="dialog"
  aria-modal="true"
  aria-labelledby="dialog-title"
  aria-describedby="dialog-desc"
>
  <h2 id="dialog-title">Delete user?</h2>
  <p id="dialog-desc">This action cannot be undone.</p>
  <button>Cancel</button>
  <button>Delete</button>
</div>

Pair this with focus management JS: trap focus inside, restore on close, Escape closes.

Automated Testing Tools

What automated scanners catch

Roughly 30–40% of WCAG issues. They find:

  • Missing alt on <img>
  • Insufficient color contrast (text only — not images)
  • Form inputs without labels
  • Empty buttons / links (no accessible name)
  • Misuse of ARIA (invalid roles, required children/parents missing)
  • Duplicate IDs, page without language, page without title

They cannot catch:

  • Whether alt text is meaningful or just "image"
  • Logical reading order across the page
  • Whether keyboard flow makes sense
  • Whether a screen reader announces something useful
  • Whether the dynamic state is announced as it changes

axe-core

npm install -D @axe-core/cli
npx axe https://app.example.com --tags wcag2a,wcag2aa

Cypress with cypress-axe

import 'cypress-axe';
 
beforeEach(() => {
  cy.visit('/');
  cy.injectAxe();
});
 
it('home page is accessible', () => {
  cy.checkA11y(null, {
    runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa'] },
  });
});
 
it('form is accessible after error', () => {
  cy.get('form').submit();          // trigger validation
  cy.checkA11y('form', {
    rules: { 'color-contrast': { enabled: false } },  // example skip
  });
});

Playwright with @axe-core/playwright

import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
 
test('home page passes WCAG AA', async ({ page }) => {
  await page.goto('/');
  const results = await new AxeBuilder({ page })
    .withTags(['wcag2a', 'wcag2aa'])
    .analyze();
  expect(results.violations).toEqual([]);
});

Pa11y

npm install -D pa11y
npx pa11y https://app.example.com
npx pa11y --standard WCAG2AA --reporter cli https://app.example.com

Lighthouse

npx lighthouse https://app.example.com --only-categories=accessibility --output=html

Built into Chrome DevTools → Lighthouse tab.

WAVE

Browser extension. Visual overlay highlights issues directly on the rendered page — great for non-technical reviewers.

eslint-plugin-jsx-a11y

Catches issues at build time, before the code even runs:

npm install -D eslint-plugin-jsx-a11y
{ "plugins": ["jsx-a11y"], "extends": ["plugin:jsx-a11y/recommended"] }

Screen Reader Testing

The big four

ReaderPlatformHow to start
VoiceOvermacOS, iOSCmd+F5 (macOS), Settings → Accessibility (iOS)
NVDAWindowsFree download from nvaccess.org
JAWSWindowsMost-used commercially; paid
TalkBackAndroidSettings → Accessibility → TalkBack

Pairings testers should know

BrowserBest paired with
SafariVoiceOver
Chrome / FirefoxNVDA / JAWS (Windows), VoiceOver (macOS)
Mobile SafariiOS VoiceOver
Mobile ChromeTalkBack

Testing checklist

  • Title — page has a descriptive title (announced first).
  • Headingsh1 says what the page is; sub-headings reflect the actual structure.
  • Landmarks — main, navigation, complementary, contentinfo are announced when navigating by region.
  • Images — informative ones have meaningful alt; decorative ones say nothing.
  • Links / buttons — accessible name says what activating them does (not "click here").
  • Forms — every input announces its label and any error.
  • State — checkboxes / toggles / expanded sections announce checked / pressed / expanded.
  • Live regions — toasts, validation, real-time data updates are announced.
  • Order — content is announced in the order it appears (and the order it appears makes sense).

Common bugs you'll find

  • Decorative SVG icons read out as their filename or "image".
  • Buttons with only an icon and no aria-label ("button" gets announced).
  • Form errors appear visually but never reach the screen reader (no aria-live / no aria-describedby linking input to error).
  • Modal opens but focus stays behind it — user has no idea anything happened.
  • Tab labels say "Tab 1, Tab 2" instead of the actual tab name.

Common A11y Patterns

Images

<!-- Informative — describe the content -->
<img src="logo.png" alt="QA Codes — testing tools and resources">
 
<!-- Decorative — empty alt removes from a11y tree -->
<img src="divider.svg" alt="">
 
<!-- Functional — describe the action, not the icon -->
<button>
  <img src="trash.svg" alt="">
  <span class="sr-only">Delete user</span>
</button>

<img> without alt at all is a failure — the screen reader will read the filename.

Forms

<label for="email">Email</label>
<input id="email" name="email" type="email" required
       aria-describedby="email-help email-error"
       aria-invalid="true">
<p id="email-help">We'll never share your email.</p>
<p id="email-error" role="alert">Email is required.</p>
  • Every input has a label (<label for> or aria-label).
  • Required fields use required attribute, not just an asterisk.
  • Errors use role="alert" so they're announced when they appear.
  • aria-invalid="true" lets screen readers say "invalid email".
  • <button> for actions (submit, open, delete, expand).
  • <a href> for navigation (different page, anchor, external link).
  • Don't substitute one for the other — they have different keyboard behavior, focus behavior, and meaning to assistive tech.

Tables

<table>
  <caption>User accounts — May 2025</caption>
  <thead>
    <tr>
      <th scope="col">Name</th>
      <th scope="col">Email</th>
      <th scope="col">Role</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">Ada Lovelace</th>
      <td>ada@example.com</td>
      <td>Admin</td>
    </tr>
  </tbody>
</table>

<caption> describes the table; scope="col" and scope="row" link headers to cells.

Headings

  • Exactly one <h1> per page (the page title).
  • Don't skip levels (<h1><h3> is wrong).
  • Use headings for structure, not styling. If you want big text, style a <p>.

Focus management in SPAs

When the route changes, focus stays where it was — usually on a now-hidden element. Move focus to the new page:

useEffect(() => {
  document.querySelector("h1")?.focus();
}, [pathname]);

The <h1> (or <main>) needs tabindex="-1" to be programmatically focusable.

Responsive

  • Don't disable pinch-zoom: <meta name="viewport" content="…"> should not include user-scalable=no or maximum-scale=1.
  • Layout must hold up at 400% zoom without horizontal scroll (in a 1280-px viewport).
  • Text spacing override (line-height 1.5×, letter-spacing 0.12×, etc.) shouldn't break the layout.

A11y Testing Checklist

Run through this on any new page or after significant UI changes:

  • All meaningful images have appropriate alt text; decorative ones have alt="".
  • No information is conveyed by color alone (e.g. red ≠ "error" — needs an icon or text too).
  • Text contrast meets 4.5:1 (3:1 for large or UI components) — check with DevTools.
  • Every interactive element is reachable, operable, and visible-when-focused via keyboard.
  • Focus order is logical (matches visual order top-to-bottom, left-to-right).
  • Form fields have visible labels and accessible programmatic labels.
  • Form errors are identified in text and announced to assistive tech.
  • Page has a descriptive <title> and one <h1>; subsequent headings don't skip levels.
  • Dynamic updates (toasts, validation, search results) are announced via aria-live.
  • Custom controls (combobox, tabs, dialog) follow WAI-ARIA Authoring Practices keyboard patterns.
  • Page works at 200% browser zoom without content loss or horizontal scroll.
  • Pinch-zoom is not disabled on mobile.
  • Audio / video has captions; transcripts available for audio-only content.
  • Run an automated scan (axe / Lighthouse) — no violations in the WCAG 2.1 AA tags.
  • Manually test the critical flows with VoiceOver or NVDA.