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
| Principle | What it means |
|---|---|
| Perceivable | Information and UI must be presented in ways users can perceive. (Alt text, captions, sufficient contrast.) |
| Operable | UI must be operable. (Keyboard, time limits, no seizures, navigable.) |
| Understandable | Information and operation must be understandable. (Readable, predictable, input help.) |
| Robust | Content must be robust enough to work with current and future tools, including assistive tech. (Valid HTML, ARIA used correctly.) |
Conformance levels
| Level | Goal | Examples of what's required |
|---|---|---|
| A (minimum) | No critical barriers | Text alternatives for images, full keyboard access, no content that flashes more than 3×/second |
| AA (industry standard) | Most products target this | 4.5:1 contrast for normal text (3:1 for large), text resizable to 200%, focus visible, consistent navigation, error identification |
| AAA (highest) | Optional / best-effort | 7: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
| Element | Minimum ratio (AA) | Enhanced (AAA) |
|---|---|---|
| Normal text (< 18 pt) | 4.5 : 1 | 7 : 1 |
| Large text (≥ 18 pt or 14 pt bold) | 3 : 1 | 4.5 : 1 |
| UI components and graphical objects | 3 : 1 | — |
| Incidental / logo / decorative text | exempt | exempt |
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 (
#9CA3AFon#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: nonewithout 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>?
Skip-link pattern
<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 notabindexand 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 element | Implicit role | When to use ARIA |
|---|---|---|
<header> (top-level) | banner | Don't — use the element |
<nav> | navigation | Multiple navs? Add aria-label to distinguish |
<main> | main | Always — there should be exactly one |
<aside> | complementary | Sidebars |
<footer> (top-level) | contentinfo | Don't — use the element |
<section> with aria-labelledby | region | When 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.
Modal dialogs
<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
alton<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
alttext 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,wcag2aaCypress 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.comLighthouse
npx lighthouse https://app.example.com --only-categories=accessibility --output=htmlBuilt 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
| Reader | Platform | How to start |
|---|---|---|
| VoiceOver | macOS, iOS | Cmd+F5 (macOS), Settings → Accessibility (iOS) |
| NVDA | Windows | Free download from nvaccess.org |
| JAWS | Windows | Most-used commercially; paid |
| TalkBack | Android | Settings → Accessibility → TalkBack |
Pairings testers should know
| Browser | Best paired with |
|---|---|
| Safari | VoiceOver |
| Chrome / Firefox | NVDA / JAWS (Windows), VoiceOver (macOS) |
| Mobile Safari | iOS VoiceOver |
| Mobile Chrome | TalkBack |
Testing checklist
- Title — page has a descriptive title (announced first).
- Headings —
h1says 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/ noaria-describedbylinking 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>oraria-label). - Required fields use
requiredattribute, not just an asterisk. - Errors use
role="alert"so they're announced when they appear. aria-invalid="true"lets screen readers say "invalid email".
Buttons vs links
<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 includeuser-scalable=noormaximum-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
alttext; decorative ones havealt="". - 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.