cf.completefrontend
My progress
FUNDAMENTALS · MODULE 12

Testing accessibility

How a team proves a UI works for people who use keyboards, screen readers, zoom, and voice control: layers of linting, automated scans, role-based unit tests, manual keyboard and screen reader passes, and real users, measured against WCAG.

A green scan and a broken page

A team ships a new checkout flow. Before release they run Lighthouse on every step and get a perfect accessibility score of 100. The axe browser extension reports zero issues. Everyone is happy.

Two weeks later a support ticket arrives from a blind customer who uses the NVDA screen reader. She cannot finish a purchase. When she picks a delivery slot, the page updates the price, but nothing is announced.

When she presses Pay, a custom dialog opens for card details, but her focus stays behind it on the Pay button, so she keeps hearing the same button. She gives up and shops somewhere else.

None of this is a scanner bug. A scanner looks at the page as it is at one moment: it can see that a button has a name and that text has enough contrast. It cannot see that focus should have moved, that a price change should have been spoken, or that a flow makes sense when you can only hear it. Those are questions about behavior over time and about meaning, and they need a person.

What accessibility testing actually means

Accessibility testing is checking that people with disabilities can perceive, operate, and understand your product. That includes people who are blind and use a screen reader, people with low vision who zoom to 400 percent, people who cannot use a mouse and rely on a keyboard or a switch device, people who speak commands with voice control, people who are deaf and need captions, and people with cognitive disabilities who need clear and predictable flows.

No single tool covers all of that. A good testing approach is a set of layers, each cheap where it can be and each catching what the layer before it missed. The cheap layers run on every commit and catch the easy mistakes in seconds. The expensive layers run less often and catch the problems that matter most to real users.

  • Linting catches mistakes in the source code as you type: an img with no alt, a div with a click handler and no keyboard handler.
  • Automated scans (axe-core, Lighthouse) check the rendered DOM against rules: missing names, bad contrast, broken references in ARIA, the attributes that add roles, names, and states to HTML.
  • Unit and integration tests written with Testing Library queries by role prove that a component exposes the right roles, names, and states, and keeps them as people change the code.
  • Manual keyboard passes check focus order, visible focus, traps, and that every action works without a mouse.
  • Screen reader passes check what is actually spoken: names, states, announcements, and reading order.
  • Testing with disabled users checks whether the whole thing is usable, not only technically correct.

How much the tools catch

Two numbers get quoted most, and they count different things. In 2017 the UK Government Digital Service (GDS) built a page full of deliberate barriers and ran automated tools against it. In the updated results (142 barriers, 13 tools), the best tool fully detected 40 percent of them, or 50 percent if you count items it flagged for a person to check.

Deque, the company behind axe-core, published a study in 2021 of its own audit data, which found that automated tests flagged about 57 percent of all issues by volume. Deque sells testing tools, so read it with that in mind. The gap is mostly about counting: a few very common issues, such as low contrast and missing labels, make up most of the volume, and tools are good at exactly those.

Both numbers say the same thing in practice. Automated tools are a fast, cheap floor, not a ceiling. A team that only scans will ship accessible-looking pages that some people still cannot use, just like the checkout above.

Six testing layers stacked like a pyramid. At the wide bottom, run by machines: lint with jsx-a11y on every save, axe scans of key states on every pull request, and unit tests by role on every commit. At the narrow top, done by people: a keyboard pass on each UI change, a screen reader pass on each feature, and testing with disabled users before big launches. Labels on the left mark the top three as people and the bottom three as machines, and a note underneath says the bottom is cheaper and faster, the top deeper.Testing with disabled usersbefore big launchesScreen reader passeach featureKeyboard passeach UI changeUnit tests by roleevery commitaxe scans of statesevery pull requestLint: jsx-a11yevery savepeoplemachinescheaper and faster towards the bottom, deeper towards the top
The bottom layers are cheap and run all the time, but they only catch what a machine can judge. The top layers cost more and catch whether people can actually use the product.

Why teams skip it, and why that costs more later

Teams skip accessibility testing for familiar reasons: nobody on the team uses a screen reader, it feels slow, and nothing on the roadmap asks for it. Then an audit, a lawsuit, or a big customer's procurement team asks for proof, and the fixes land all at once on finished code.

Fixing a missing label at the design stage costs a sentence in a spec. Fixing it in a shared design system component after 200 screens use it costs a migration. The point of this lesson is to make testing small, routine, and early, so that accessibility is checked the same way as everything else you ship.

Check your understanding · Exercise 1

The checkout passed Lighthouse and axe with no issues. Which of these problems could still be present? Select all that apply.

What you test against: WCAG and its levels

A test needs a pass condition. For accessibility, the shared pass condition is WCAG, the Web Content Accessibility Guidelines from the W3C. Laws, contracts, audits, and tools all point to it, so it is the language your test results should speak.

Principles, guidelines, and success criteria

WCAG has four principles, often shortened to POUR: content must be perceivable, operable, understandable, and solid enough to work with current and future assistive technology (that last one is the R). Under them sit guidelines, and under the guidelines sit success criteria: the testable statements you actually check. Each criterion has a number, a name, and a level, such as "1.4.3 Contrast (Minimum), level AA".

The current version is WCAG 2.2, a W3C Recommendation since October 2023. It has 86 success criteria. Versions are backward compatible: a page that meets 2.2 also meets 2.1 and 2.0 at the same level. The one exception is 4.1.1 Parsing, which 2.2 removed because browsers and assistive technology now handle the markup errors it covered.

The three levels

Every criterion sits at one of three levels. The levels stack: to meet AA you must meet every A criterion and every AA criterion. There is no "AA without A".

  • Level A is the floor. Without it, some people cannot use the page at all. Examples: text alternatives for images (1.1.1), everything works from a keyboard (2.1.1), no keyboard trap (2.1.2), color is not the only way to show information (1.4.1), and every control has a name and role that assistive technology can read (4.1.2).
  • Level AA removes the biggest everyday barriers. Examples: text contrast of at least 4.5:1 (1.4.3), text resizes to 200 percent (1.4.4), content reflows at a width of 320 CSS pixels without sideways scrolling (1.4.10), visible focus (2.4.7), and status messages announced without moving focus (4.1.3). WCAG 2.2 added AA criteria for focus not hidden behind sticky headers (2.4.11), a single-pointer alternative to dragging (2.5.7), targets of at least 24 by 24 CSS pixels (2.5.8), and login that does not depend on a memory test (3.3.8).
  • Level AAA is the strictest. Examples: contrast of 7:1 (1.4.6), sign language for recorded video (1.2.6), and no time limits at all (2.2.3). The W3C itself says AAA is not recommended as a policy for whole sites, because some content cannot meet it.
Three nested regions. The outer region is level AAA, the strictest, with examples 7 to 1 contrast, sign language for video, and no time limits. Inside it is level AA, the usual target, with contrast of 4.5 to 1 for text, focus visible, reflow at 320 CSS pixels, and targets of 24 by 24 pixels. Inside that is level A, the floor, with alt text, keyboard access, no keyboard traps, and a name and role for every control.Level AAA: strictest, not a whole-site target7:1 contrastsign languageno time limitsLevel AA: the usual targetcontrast4.5:1 textfocusvisible, 2.4.7reflow320 CSS pxtargets24 by 24 pxLevel A: the flooralt text1.1.1keyboard2.1.1no traps2.1.2name, role4.1.2
Each level contains the one inside it: meeting AA means meeting every A and every AA criterion. Most laws and contracts ask for AA; AAA is used criterion by criterion, not for whole sites.

Which level to target

For almost every team the answer is WCAG 2.2 level AA, or 2.1 AA where a law or contract names 2.1. Meet AAA criteria where they are cheap and help your users, such as larger targets or higher contrast for body text, but do not promise AAA for a whole product.

The legal picture changes, differs by country, and is not legal advice here. But as of 2025 the direction is the same everywhere: AA is the level that rules refer to.

A summary, not legal advice. Check the current text and dates for your market with your legal team.
RuleWho it coversWhat it points to
European Accessibility Act (applies from 28 June 2025)Many consumer products and services sold in the EU, such as e-commerce, banking, e-books, and transport ticketing. Micro-enterprises offering services are exempt.The harmonised standard EN 301 549, whose web section maps to WCAG 2.1 AA.
ADA Title II rule (US Department of Justice, April 2024)US state and local governments, including public universities.WCAG 2.1 AA. A 2026 extension moved the deadlines to April 2027 for large governments and April 2028 for small ones.
ADA Title III (US)Businesses open to the public.No technical rule in the law itself, but lawsuits and settlements usually name WCAG 2.x AA.
Section 508 (US)US federal agencies and what they buy.WCAG 2.0 AA, so vendors are asked for proof (see ACRs later).

Two practical points follow. First, conformance applies to whole pages and complete processes: a checkout meets AA only if every step meets it, including the third-party payment widget. Second, "we target AA" means nothing without tests that check it, which is the rest of this lesson.

Check your understanding · Exercise 2

Your product team says "we meet WCAG 2.2 AA". Which statements must then be true? Select all that apply.

The automated layer: linting, axe, and Lighthouse

Automated checks are the base of the pyramid because they are cheap to run, never get tired, and run on every change. There are two kinds, and they see different things. Static checks read your source code. Runtime checks look at the rendered page in a browser or a browser-like environment.

Linting: catch it while typing

For React, eslint-plugin-jsx-a11y reads JSX and flags problems it can see without running anything. It knows which elements are interactive, which ARIA attributes exist, and which roles need which attributes. Vue has eslint-plugin-vuejs-accessibility and Angular has template accessibility rules in angular-eslint.

js
// eslint.config.js
import jsxA11y from "eslint-plugin-jsx-a11y";

export default [
  jsxA11y.flatConfigs.recommended,
  {
    rules: {
      // Make the common ones errors, so new code cannot add them
      "jsx-a11y/alt-text": "error",
      "jsx-a11y/click-events-have-key-events": "error",
      "jsx-a11y/no-static-element-interactions": "error",
      "jsx-a11y/label-has-associated-control": "error",
      "jsx-a11y/aria-props": "error",
    },
  },
];
The recommended preset plus a few rules promoted to errors. Run the same config in the editor and in CI (continuous integration, the checks that run on every push).

What lint catches well: img without alt, a click handler on a div with no key handler, an a with no href, a misspelled aria-* attribute, a role missing its required attributes, and autoFocus.

What it cannot see: any value that comes from props or state at runtime. If your Icon component renders the img, the linter only sees Icon, and it has no idea whether a label reaches the DOM. Design systems can map their components to HTML elements in the plugin's settings, which helps a little.

axe-core: the engine most tools run

axe-core is an open-source rules engine from Deque. You give it a DOM, it runs about a hundred rules, and it returns results in four groups: violations (failed), passes, incomplete (it could not decide, so a person must check), and inapplicable. Each violation has a rule id, an impact (minor, moderate, serious, or critical), the WCAG criteria it maps to as tags, the failing elements as CSS selectors, and a help link.

axe-core is built to avoid false positives: when it is not sure, it reports incomplete rather than a violation. That is why teams can fail a build on its violations without endless arguments. The price is that it stays quiet about many real problems. Treat incomplete as a to-do list for manual review, not as noise.

The result of one axe run. Four groups at the top: 3 violations, 41 passes, 2 incomplete that need a person to check, and 37 inapplicable rules. Below, one violation opened up: id button-name is the rule that failed, impact critical is the general severity, tags wcag2a and wcag412 map it to WCAG 4.1.2 level A, target is the CSS selector of the failing element, and helpUrl links to how to fix it.violations 3passes 41incomplete 2inapplicable 37one violation, opened up"id": "button-name"the rule that failed"impact": "critical"general severity"tags": ["wcag2a", "wcag412"]WCAG 4.1.2, level A"target": ["#cart .icon-btn"]the failing element"helpUrl": ".../button-name"how to fix it
Every violation names a rule, a WCAG criterion, and the exact element. The incomplete group is easy to ignore and should not be: it is axe saying "I could not decide, a person must look".

The same engine shows up in many places: the axe DevTools browser extension, Lighthouse, jest-axe and vitest-axe for component tests (covered in the unit and integration testing lesson), @axe-core/playwright and cypress-axe for end-to-end tests, and Storybook's accessibility addon. Because they share rules, a violation means the same thing in every layer.

Lighthouse and the trouble with one number

Lighthouse, built into Chrome DevTools, runs a subset of axe-core rules and turns them into a score from 0 to 100. Each audit is pass or fail for the whole page and has a weight. One unlabeled button and fifty unlabeled buttons fail the same audit and cost the same points, so the score says little about how much work is left.

Lighthouse also lists "additional items to manually check", such as logical tab order and focus moving into new content. They do not affect the score. Many people never scroll down to read them, which is exactly where the checkout team's bugs lived. Use Lighthouse for a quick look and for trends; use axe with a list of violations for gates.

Storybook and Playwright: scanning states, not pages

A scan only sees what is rendered. A menu that is closed, a form that has not been submitted, and a dialog that has not opened are invisible to it, and those are where most bugs hide. So the rule is: scan every state that matters, not every URL.

  • Storybook: @storybook/addon-a11y runs axe on each story and shows the results in a panel. Write a story per state (default, open, error, disabled, long text) and every state gets scanned. When stories run as tests with the Vitest addon, set parameters.a11y.test to "error" so violations fail CI; "todo" only shows warnings.
  • Playwright: @axe-core/playwright scans a real browser page, so it can check contrast, which needs layout and real colors. Drive the page into each state first, then scan.
ts
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";

const WCAG_AA = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"];

test("checkout payment step, error state", async ({ page }) => {
  await page.goto("/checkout/payment");
  await page.getByRole("button", { name: "Pay now" }).click(); // empty form: show errors

  const results = await new AxeBuilder({ page })
    .withTags(WCAG_AA)
    .exclude("#third-party-chat") // tracked separately with the vendor
    .analyze();

  expect(results.violations).toEqual([]);
});
Drive the page into a state, then scan. Tags limit the run to WCAG A and AA rules; exclude skips a widget you cannot fix today.
A matrix of eight issues against five checks: lint, axe, unit tests, a keyboard pass, and a screen reader pass. Image without alt: lint yes, axe yes, unit partly, keyboard no, screen reader yes. Low contrast: only axe. Div used as a button: lint, unit, keyboard, and screen reader yes, axe no. Broken ARIA id reference: lint partly, axe yes, unit partly, screen reader yes. Focus not returned: unit, keyboard, and screen reader. Silent update: unit partly, screen reader yes. Useless alt text: only the screen reader pass. Confusing flow: keyboard and screen reader only partly.LintaxeUnitKeysReaderimage without altyesyespartnoyeslow contrastnoyesnononodiv used as buttonyesnoyesyesyesbroken ARIA idpartyespartnoyesfocus not returnednonoyesyesyessilent updatenonopartnoyesuseless alt textnonononoyesconfusing flownononopartpartyes: finds it reliably, part: only if the test or tester looks for it
Read down a column to see what a check is good at, and across a row to see who can catch an issue. The bottom rows, about meaning and flow, are where only people help, and even they only partly without real users.

Decision Where should automated scans run in a typical React app?

Component tests in jsdom (jest-axe or vitest-axe)

Render a component with Testing Library in jsdom (a simulated DOM with no layout), pass the container to axe, expect no violations.

Pros

  • Fast, runs with unit tests
  • Catches naming and ARIA mistakes per component

Cons

  • jsdom has no layout, so contrast and visibility rules cannot run
  • Sees one component, not the page around it

Storybook stories

The a11y addon scans every story; CI runs all stories as tests.

Pros

  • Every documented state is scanned for free
  • Real browser rendering, so contrast works

Cons

  • Only as good as your story coverage
  • Does not see page-level issues like landmarks or headings

End-to-end tests (Playwright plus axe)

Visit key pages, drive them into key states, scan with AxeBuilder.

Pros

  • Real pages with real data, layout, and third-party code
  • Finds page-level issues: landmarks, heading order, duplicate ids

Cons

  • Slower, so only key flows
  • Failures point at pages, not at the component to fix

What we pick: Use at least two. Storybook (or component tests) catches problems where they are cheapest to fix, in the shared component. Playwright scans of the five to ten most important flows catch what only appears when everything is put together. Lighthouse stays a manual quick look.

Check your understanding · Exercise 3

Which of these would an axe scan in a Playwright test catch? Select all that apply.

Unit tests that speak the accessibility tree

Scans check rules. Unit and integration tests check your component's contract: this button is a button, it is called "Save", it is pressed, and pressing Escape closes this dialog and puts focus back. Written well, ordinary component tests become accessibility tests at no extra cost.

The accessibility tree in one minute

The browser builds a second tree next to the DOM: the accessibility tree. Each node has a role (button, link, textbox, dialog), an accessible name (what a screen reader says for it), sometimes a description, and states (checked, expanded, pressed, disabled). Screen readers, voice control, and switch devices read this tree, not your CSS and not your class names.

Many DOM nodes never make it in. A wrapper div with no role is skipped. An element with display: none or aria-hidden="true" is gone. An icon button with no text and no aria-label is there, but with an empty name, which a screen reader announces as just "button".

Two trees. On the left, the DOM of a project card: a div containing an img with empty alt, an h3 with the text Atlas, and a div holding a span with an onClick handler and a button with only an icon inside. On the right, the accessibility tree built from it: a root with three children, a heading named Atlas, the word Edit as plain text, and a button with an empty name. The two wrapper divs and the image with empty alt were dropped.DOMdivimgh3divalt=""AtlasspanbtnonClickicon onlyAccessibility treerooth3txtbtnheading "Atlas""Edit", plain textbutton, name ""dropped: both wrapper divsand the image with alt=""
Assistive technology and getByRole both see the right-hand tree. The span with a click handler is just text there, and the icon button has no name, so a query for a button named "Edit" finds nothing.

Query by role and name

Testing Library's getByRole searches the accessibility tree the way assistive technology does. getByRole("button", { name: "Save" }) passes only if there is an element whose computed role is button and whose computed accessible name is "Save". Turn the button into a div, or drop its label, and the test fails. That is the whole trick.

Compare getByTestId("save"). It passes whatever the element is, and test ids are invisible to users. Testing Library's own guidance puts role queries first, then label and text queries, and test ids last. Keep test ids for things that truly have no role or name, such as a chart's container.

<div data-testid="save" onClick={save}>Save</div>

What each check sees for this markup
CheckResult
Role in the accessibility treenone (generic)
Accessible namenone
Screen reader saysSave (as plain text)
Reached with Tabno
Enter or Space activates itno
getByRole("button", { name: "Save" })throws: not found
getByTestId("save")found
axe scanno violations: axe cannot see the click handler

The test id query passes and axe is silent, yet keyboard and screen reader users cannot use it. Only the role query, lint, or a keyboard pass catch it.

Assert states and keyboard behavior

Role queries take state options too: { pressed: true }, { expanded: false }, { checked: true }, { selected: true }, { level: 2 } for headings. The jest-dom matchers add toHaveAccessibleName, toHaveAccessibleDescription, toHaveAccessibleErrorMessage, toBeDisabled, and toHaveFocus. With user-event you can press Tab, Enter, Escape, and arrow keys, then check where focus went.

tsx
test("delete dialog: focus in, Escape out, focus back", async () => {
  const user = userEvent.setup();
  render(<ProjectRow name="Atlas" />);

  const trigger = screen.getByRole("button", { name: "Delete Atlas" });
  await user.click(trigger);

  const dialog = screen.getByRole("dialog", { name: "Delete project?" });
  expect(within(dialog).getByRole("button", { name: "Cancel" })).toHaveFocus();

  await user.keyboard("{Escape}");
  expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
  expect(trigger).toHaveFocus();
});

test("invalid email is announced as an error on the field", async () => {
  const user = userEvent.setup();
  render(<SignupForm />);
  await user.click(screen.getByRole("button", { name: "Create account" }));

  const email = screen.getByRole("textbox", { name: "Email" });
  expect(email).toHaveAttribute("aria-invalid", "true");
  expect(email).toHaveAccessibleErrorMessage("Enter an email address");
});
Two tests that check focus management and error wiring, the things scans cannot see. Each line reads like a manual test step.

What to test per component

For a widget that follows a WAI-ARIA Authoring Practices pattern, the pattern's keyboard table is a ready-made test list. For a tabs component, test that Tab enters the tab list once, arrow keys move between tabs, Home and End jump, the selected tab has aria-selected="true", and its panel is labelled by it. For a combobox, test aria-expanded, the active option, and that Escape closes the list without clearing the input.

  • Name and role of every control a user touches.
  • State after each interaction: expanded, pressed, checked, selected, invalid.
  • Focus: where it lands on open, where it moves on keys, where it returns on close.
  • Announcements: the status or alert region contains the right text after an action.
  • Optionally an axe scan of the rendered component with jest-axe or vitest-axe for naming and ARIA rules.

What jsdom cannot tell you

Most unit tests run in jsdom, a JavaScript model of the DOM with no layout and no rendering. Role and name computation there comes from Testing Library's own libraries, which follow the specs closely but are not a browser, and they only know about CSS that is actually loaded in the test.

So jsdom tests cannot check contrast, visible focus rings, zoom and reflow, target size, or what a real screen reader says. Those checks belong in a browser: Storybook, Playwright, or a person.

Check your understanding · Exercise 4

A test uses getByRole("button", { name: "Close" }). Which changes to the component would make it fail? Select all that apply.

Manual passes: keyboard, screen reader, zoom

Everything so far runs in CI. This chapter is about the checks a person does with their hands and ears. They take 10 to 20 minutes per feature once you have practiced, and they catch the bugs that hurt users most: traps, lost focus, silent updates, and flows that make no sense without sight.

The goal is not to become an expert screen reader user. Daily users move far faster than you will and use features you will never touch. The goal is a smoke test: does the basic flow work, and does anything obviously go wrong?

The keyboard pass

Put the mouse away, or better, unplug it. Start at the top of the page with focus in the address bar, and walk the feature with Tab, Shift+Tab, Enter, Space, Escape, and the arrow keys. On macOS, first turn on Keyboard navigation in System Settings, and in Safari turn on "Press Tab to highlight each item on a webpage" in the Advanced settings; otherwise Tab skips buttons and links.

  1. Reach: can every control you can click also be reached and used by keyboard? Custom dropdowns, sliders, and drag-and-drop are the usual failures.
  2. See: is there a clearly visible focus indicator at every step, never hidden behind a sticky header or cookie banner?
  3. Order: does focus move in the same order as the visual layout and reading order? A jump to the footer and back is a bug.
  4. Escape: can you leave every popup, menu, and dialog with Escape, and does focus return to the control that opened it?
  5. Never stuck: is there no place where Tab cycles forever, except inside an open modal dialog where that is intended?
  6. Skip: is there a skip link or landmarks so you do not have to tab through 40 navigation links on every page?

The screen reader pass

Pick pairs that real users use. In WebAIM's tenth screen reader survey (2024), the primary desktop readers were JAWS (40.5 percent) and NVDA (37.7 percent) on Windows, then VoiceOver (9.7 percent). On mobile, most respondents used VoiceOver on iOS, then TalkBack on Android. A sensible rotation is NVDA with Chrome or Firefox, VoiceOver with Safari on macOS and iOS, and TalkBack with Chrome on Android, with JAWS before big releases if your users are in enterprises or government.

Enough keys for a smoke test. Tab still moves between focusable controls in both.
TaskNVDA (Windows)VoiceOver (macOS)
StartControl+Alt+NCommand+F5
Read next itemDown ArrowVO+Right Arrow (VO is Control+Option)
Next headingHVO+Command+H
List headings, links, landmarksNVDA+F7 (NVDA is Insert)VO+U, the rotor
Next form fieldFVO+Command+J
Stop speakingControlControl

Then run a short script, the same one every time, so results are comparable between releases:

  1. Page: on load, is the page title read and does it say where you are? After a client-side route change, is the new page announced or focus moved to its heading?
  2. Structure: open the headings list. Is there one h1, and do the headings outline the page? Open the landmarks list: is there a main region?
  3. Controls: Tab through the feature. For each control, is the name clear on its own, the role right, and the state spoken ("collapsed", "checked", "selected")?
  4. Changes: trigger each async update: add to cart, filter results, save. Is the result announced, once, without moving focus away from where you are?
  5. Errors: submit the form empty. Are errors announced, and does each field say what is wrong when you reach it?
  6. Dialogs: open each dialog. Is its title read, is focus inside, and is the page behind it unreachable until it closes?
A sequence diagram with four columns: the tester, the screen reader, the browser, and the page. The tester presses Tab. The screen reader asks the browser to move focus, the browser reports focus on a button, and the screen reader says Pay now, button. The tester presses Enter. The screen reader activates the button, the browser sends a click event to the page, and the page adds a dialog. But focus never moved, so the screen reader says nothing: silence.TesterScreen readerBrowserPageTabmove focusfocus: buttonPay now, buttonEnteractivateclick eventdialog added... silence
The first exchange is what a pass should sound like. The second one is the checkout bug from the start of the lesson: the dialog appeared on screen, but focus stayed behind it, so a listener hears nothing at all. Only a person running the script notices.

Listen for what you hear, not what you see. "Button" with no name, "link, image, IMG_0423", the same "Read more" ten times in a links list, or silence after pressing Save are all findings. Write down exactly what was spoken; it makes the bug report undeniable.

Build:
Step 1 of 6: fail, 2.4.2 Page Titled
DoLoad the Projects page and listen.
ExpectA page title that says where you are.
Heard"Acme" (every page has the same title)

Findings so far: 1. 2.4.2 Page Titled. An axe scan limited to WCAG rules would have reported only the unnamed button.

Zoom, reflow, and user settings

Three more quick checks need only the browser. Zoom to 200 percent: does all text grow, and does nothing get cut off? Set the window to 1280 pixels wide and zoom to 400 percent, which gives a 320 pixel layout: does content reflow into one column without sideways scrolling (WCAG 1.4.10)? Then turn on reduced motion, a dark theme, and Windows contrast themes (or forced-colors emulation in DevTools), and look for icons, borders, or focus rings that vanish.

Voice control is worth one pass for important flows. With Voice Control on macOS or iOS, or Voice Access on Android and Windows, users say "click Submit". That only works if the accessible name contains the visible label (WCAG 2.5.3, Label in Name). A button that shows "Submit" but has aria-label="Send form data" cannot be clicked by voice.

Check your understanding · Exercise 5

During a keyboard pass on a product page, which of these are WCAG failures you should log? Select all that apply.

Real users and a written test plan

Tools and team members can tell you whether the product meets WCAG. Only disabled people who use assistive technology every day can tell you whether it is usable. The two are different: a form can meet every criterion and still take a screen reader user ten minutes because the error summary lists 14 problems in an order that does not match the page.

Testing with disabled users

Usability sessions with disabled participants work like any other usability study, with a few differences. Recruit through disability organizations or specialist panels and pay people properly for their time. Let them use their own device, their own screen reader, and their own settings, usually in a remote session, because a borrowed laptop with default speech rate tells you about the laptop, not about your product.

  • Give tasks, not tours: "buy a size 9 in blue and have it delivered on Friday", not "try the size picker".
  • Mix needs: screen reader users, screen magnifier users, keyboard-only or switch users, voice control users, and people with cognitive or learning disabilities find different problems.
  • Small rounds, often: a common usability rule of thumb (Jakob Nielsen's "five users") is that a handful of participants finds most of the obvious problems in a flow. No one disability group speaks for all, so vary who you recruit, and run a second round after fixes to check they worked.
  • Watch, do not help: when someone is stuck, note where and what they heard, then ask afterwards.

This is the most expensive layer, so spend it on the flows that matter most: sign up, sign in, search, checkout, and the core task of your product. An expert audit by an accessibility specialist sits next to it: the expert checks every WCAG criterion systematically, the users show what actually blocks them.

A test plan for a component

A test plan says what is checked, by which layer, and when. Writing it down is what turns "we care about accessibility" into something a reviewer can verify. Here is one for a design system's combobox:

CheckLayerWhen
No invalid ARIA, labels wired in sourceLintEvery save and every pull request (PR)
Role combobox, name from its label, aria-expanded togglesUnit test with getByRoleEvery PR
Arrow keys move the active option, Enter selects, Escape closesUnit test with user-eventEvery PR
Result count announced in a status regionUnit test on the region's textEvery PR
No axe violations in default, open, empty, error, disabled statesStorybook scan in a real browserEvery PR
Contrast and focus ring in light, dark, and forced colorsStorybook scan plus visual checkEvery PR touching styles
Works with NVDA plus Chrome and VoiceOver plus SafariManual screen reader scriptBefore each release
Works at 400 percent zoom and with voice controlManual checkBefore each release

A test plan for a page

Pages add things no component can know about: one h1, a sensible heading outline, landmarks, a unique page title, skip links, focus after route changes, and how components behave together. A page plan reuses the component results and adds those checks, plus an axe scan of each key state in Playwright and a manual pass of the full task.

A wireframe of a checkout page with six numbered checks. 1: the page title and a single h1 that says Checkout. 2: a skip link and landmarks in the header. 3: labels and announced errors on the form fields. 4: the card details dialog takes focus when it opens. 5: the Address saved status message is announced. 6: the whole page reflows at 400 percent zoom.CheckoutSkip to contentCheckoutPay nowCard detailsAddress saved1234561title and one h12skip link, landmarks3labels and errors4dialog takes focus5status announced6reflow at 400%
Components bring their own tests; the page plan adds what only exists when they are put together. Each number is also a state to scan with axe in Playwright and a step in the manual script.

Lab: which method finds which bug

The best way to feel the layers is to sort real bugs into them. For each issue below, pick the cheapest method that reliably finds it, then check your answers and look at the coverage bar. Notice how many issues need a person.

Think first

A startup has two frontend engineers and no accessibility specialist. What is a realistic test plan for their first year?

Triage, severity, and stopping regressions

The first full scan of an existing product is often humbling: hundreds of violations, plus a long list from the manual passes. You cannot fix everything this sprint, and you should not try in random order. Two habits make the backlog manageable: rate each issue by its impact on users, and stop new issues from arriving while you fix the old ones.

Severity: what does it stop someone from doing?

Rate severity by what happens to a person, not by which tool found it. axe's impact levels (critical, serious, moderate, minor) are a useful hint, but they describe the rule in general. A missing label on a newsletter field in the footer and on the card number field in checkout have the same axe impact and very different consequences.

  • Blocker: some users cannot complete a core task at all. A keyboard trap in checkout, an unlabeled Pay button, a CAPTCHA with no alternative, focus lost when a required dialog opens.
  • Serious: the task is possible but very hard or needs a workaround. A custom dropdown that works only with arrow keys after a mouse click, error messages that are shown but not announced.
  • Moderate: friction and confusion. Ten links all called "Read more", a skipped heading level, a focus ring with weak contrast.
  • Minor: polish. A decorative icon that is announced, a redundant title attribute.

Then multiply by reach: how many users and pages it touches, and whether it sits on a critical path. A serious bug in the shared Select component that 300 screens use comes before a blocker on a settings page that 0.1 percent of users visit. Fixing shared components first gives you the most improvement per hour of work.

A flowchart for rating severity. First question: does the issue block a core task for any user? Yes means Blocker. No leads to: is it possible but hard, needing a workaround? Yes means Serious. No leads to: is it confusing or slow, friction only? Yes means Moderate, no means Minor. A bracket groups all four results with a note: then multiply by reach, meaning users, flows, and shared components.Blocks a core task?for any useryesBlockernoPossible, but hard?needs a workaroundyesSeriousnoConfusing or slow?friction onlyyesModeratenoMinortimes reachusers, flowsand sharedcomponents
Severity comes from what happens to a person, not from the tool that found the issue. Priority is severity times reach, which is why a serious bug in a shared component often beats a blocker on a rarely used page.

A good accessibility bug report has the same parts every time: the WCAG criterion (for example 2.1.2 No Keyboard Trap), the page and state, the steps, the assistive technology and browser with versions, what was expected, and what actually happened, including the exact words spoken. That last part removes most arguments about whether it is really a bug.

Regressions in CI

Accessibility regresses quietly. A refactor swaps a button for a styled div, a new color token drops contrast to 3.9:1, a dialog stops returning focus. Nobody on a mouse-and-screen team notices. CI is where you make these regressions loud.

A timeline from save to big launch. Checks that run by themselves: lint on save, unit tests by role on commit, axe scans of Storybook stories and key pages on each pull request, and a scheduled production scan after release. A note under the axe scans says: baseline of 340 known issues, fail only on new ones. Checks that need a person: a PR checklist with the keyboard and screen reader script on each pull request, a screen reader script before each release, and tests with disabled users before a big launch.runs by itselflintunit testsaxe scansprod scanbaseline: 340 known issuesfail only on new onesneeds a personPR checklistSR scriptuser testssavecommitpull requestreleasebig launch
Machines guard every change; people guard every release. The baseline lets you turn axe on today in an app that already has hundreds of violations, and the production scan catches changes that never went through a pull request.

Decision Your app has 340 existing axe violations. How should CI treat them?

Block on any violation

The axe step fails the build if violations is not empty.

Pros

  • Simple and strict
  • Right for new projects and for the design system

Cons

  • On an old app, every PR fails on day one, so teams turn the check off

Baseline and ratchet

Save today's violations as a baseline file, keyed by rule id and page plus selector.

Fail only on violations not in the baseline.

When a fix lands, remove it from the baseline, so the count can only go down.

Pros

  • No new bugs from day one
  • Progress is visible as the baseline shrinks

Cons

  • Selectors change, so fingerprints need care
  • Old bugs can sit in the baseline forever without an owner and a date

Report only

Run scans and post results as a PR comment or dashboard, never fail.

Pros

  • Zero friction
  • Useful as a first week to measure

Cons

  • Reports get ignored; counts go up over time

What we pick: Block on everything for new code and the design system. For an existing app, use a baseline with a ratchet, start the burn-down with blockers on the most used flows, and give the baseline an owner and a target date. Report-only is a measuring step, not a strategy.

Automated gates protect only what they can see. For manual checks, the protection is process: a UI pull request template with a checkbox for "keyboard and screen reader script run, notes attached", accessibility acceptance criteria written into tickets ("the result count is announced"), and a role-based unit test added for every manual bug you fix, so that bug cannot come back silently.

Check your understanding · Exercise 6

You have time for two fixes this sprint. Which two should come first? Select all that apply.

Proving it: VPATs, ACRs, and statements

Sooner or later someone outside the team asks for proof. A university wants to buy your product, a government agency has a procurement rule, or a large company's legal team needs evidence before signing. The format they ask for is almost always an ACR.

VPAT is the template, ACR is the report

A VPAT, the Voluntary Product Accessibility Template, is a document template published by the Information Technology Industry Council (ITI). When you fill it in for your product, the result is an ACR, an Accessibility Conformance Report. People often say "send us your VPAT" when they mean the filled-in ACR.

The template comes in editions for different rules: a WCAG edition, a Section 508 edition for US federal buyers, an EU edition for EN 301 549, and an international edition that covers all three. Each has a table with one row per criterion and three columns: the criterion, a conformance level, and remarks.

Four example rows. The main levels are Supports, Partially Supports, Does Not Support, and Not Applicable; Not Evaluated is allowed only for AAA criteria.
CriterionConformance levelRemarks and explanations
1.1.1 Non-text Content (A)SupportsAll product images have text alternatives. Decorative images are hidden from assistive technology.
1.4.3 Contrast (Minimum) (AA)Partially SupportsBody text meets 4.5:1. Placeholder text in the search field is 3.2:1; fix planned for Q3.
2.1.1 Keyboard (A)Partially SupportsAll features work by keyboard except reordering items in the playlist editor, which needs a mouse. Alternative: Move up and Move down buttons in the item menu.
1.2.5 Audio Description (Prerecorded) (AA)Not ApplicableThe product has no prerecorded video.

What makes an ACR worth anything

An ACR is only as good as the testing behind it. Experienced buyers can tell a real one from a marketing document. A real one names the product version, the date, the evaluation methods ("automated testing with axe-core, manual keyboard testing, NVDA 2025 with Chrome, VoiceOver with Safari on iOS 18"), and it uses "Partially Supports" with clear remarks where that is the truth.

  • Every row needs evidence: the test plan from this lesson is where it comes from. Lint, scans, and unit tests cover part of some rows; manual and expert checks cover the rest.
  • "Supports" everywhere is a red flag: almost no large product meets every criterion, and a buyer who tests and finds a gap will distrust the whole document.
  • Remarks explain impact and plans: what does not work, who it affects, the workaround, and when it will be fixed.
  • Keep it current: an ACR from three years and forty releases ago describes a different product. Update it at least yearly and after big redesigns.

Many teams also publish an accessibility statement: a public page that names the standard you target, known issues, and how to report a problem or get content in another format. Public sector websites in the EU have been required to publish one for years, and the European Accessibility Act asks service providers to explain how they meet its requirements. A working feedback channel on that page is also a source of real bug reports from real users.

Check your understanding · Exercise 7

A buyer asks for your ACR. Which statements are true? Select all that apply.

Accessibility testing in a system design interview

In a frontend system design interview, accessibility usually comes up in one of three ways: as a non-functional requirement you list at the start, as a follow-up ("how would you make sure this is accessible?"), or as part of a design system or component question. Testing is how you turn the requirement into something credible.

When to bring it up and what to say

Name the target in your requirements: "WCAG 2.2 AA, keyboard and screen reader support". Then, when you design each interactive piece, mention the pattern it follows and the one or two behaviors that matter, such as focus moving into the dialog. At the end, in the quality or testing part, give the layered plan in one breath.

How deep to go by level

LevelWhat a good answer covers
JuniorSemantic HTML first; getByRole in tests; runs axe or Lighthouse; knows a score is not the whole story; can do a keyboard pass.
Mid-levelNames WCAG AA and a few criteria; scans states, not pages; tests focus and announcements in unit tests; knows what jsdom cannot check; has run a screen reader.
SeniorDesigns the whole plan per layer and per release; baseline and ratchet in CI; severity times reach for triage; shared components as the lever; ACRs and legal context; budgets for user testing.

If the question is a component like an autocomplete or a date picker, draw the accessibility contract next to the component: its role, name, states, and keyboard table. Then say how each row is tested. That small table shows more understanding than a long list of tools.

Check your understanding · Exercise 8

The interviewer asks: "How do you know your new data grid is accessible?". Which parts belong in a strong answer? Select all that apply.

Summary

  • Test against a named target: WCAG 2.2 AA is the usual one, and the levels stack, so AA includes all of A.
  • Automated tools are a fast floor, not a ceiling. A Lighthouse score of 100 means no failures among the rules it runs, not an accessible page.
  • Use layers: lint in the editor, role-based unit tests, axe scans of key states in a real browser, then manual keyboard and screen reader passes.
  • Query by role and name in component tests. If getByRole is hard to write, users probably cannot find the element either.
  • Scan states, not URLs: open menus, dialogs, and error states are where bugs hide.
  • Triage by what an issue stops a person from doing, times how many people and flows it touches. Fix shared components first.
  • Stop regressions with a CI baseline that can only shrink, and add a test for every manual bug you fix.
  • Real disabled users show usability problems that conformance checks miss; an honest ACR needs evidence from every layer.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

Our Lighthouse accessibility score is 100, but a blind user says they cannot finish checkout. How is that possible, and what do you do?

Question 2

How would you set up accessibility testing for a design system used by 40 product teams?

Question 3

What can automated accessibility tools not catch? Give examples.

Question 4

Which WCAG level would you target, and why not AAA?

Question 5

An old app has 340 axe violations. How do you add accessibility checks to CI without blocking every team?

Practice it

These design problems lean on testing accessibility.

References

Read next