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
imgwith noalt, adivwith 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.
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.
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.
| Rule | Who it covers | What 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.
// 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",
},
},
];
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.
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-a11yruns 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, setparameters.a11y.testto"error"so violations fail CI;"todo"only shows warnings. - Playwright:
@axe-core/playwrightscans a real browser page, so it can check contrast, which needs layout and real colors. Drive the page into each state first, then scan.
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([]);
});
exclude skips a widget you cannot fix today.Decision Where should automated scans run in a typical React app?
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".
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>
| Check | Result |
|---|---|
| Role in the accessibility tree | none (generic) |
| Accessible name | none |
| Screen reader says | Save (as plain text) |
| Reached with Tab | no |
| Enter or Space activates it | no |
getByRole("button", { name: "Save" }) | throws: not found |
getByTestId("save") | found |
| axe scan | no 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.
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");
});
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-axeorvitest-axefor 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.
- 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.
- See: is there a clearly visible focus indicator at every step, never hidden behind a sticky header or cookie banner?
- 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.
- Escape: can you leave every popup, menu, and dialog with Escape, and does focus return to the control that opened it?
- Never stuck: is there no place where Tab cycles forever, except inside an open modal dialog where that is intended?
- 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.
| Task | NVDA (Windows) | VoiceOver (macOS) |
|---|---|---|
| Start | Control+Alt+N | Command+F5 |
| Read next item | Down Arrow | VO+Right Arrow (VO is Control+Option) |
| Next heading | H | VO+Command+H |
| List headings, links, landmarks | NVDA+F7 (NVDA is Insert) | VO+U, the rotor |
| Next form field | F | VO+Command+J |
| Stop speaking | Control | Control |
Then run a short script, the same one every time, so results are comparable between releases:
- 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?
- 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? - 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")?
- Changes: trigger each async update: add to cart, filter results, save. Is the result announced, once, without moving focus away from where you are?
- Errors: submit the form empty. Are errors announced, and does each field say what is wrong when you reach it?
- Dialogs: open each dialog. Is its title read, is focus inside, and is the page behind it unreachable until it closes?
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.
| Do | Load the Projects page and listen. |
|---|---|
| Expect | A 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:
| Check | Layer | When |
|---|---|---|
| No invalid ARIA, labels wired in source | Lint | Every save and every pull request (PR) |
Role combobox, name from its label, aria-expanded toggles | Unit test with getByRole | Every PR |
| Arrow keys move the active option, Enter selects, Escape closes | Unit test with user-event | Every PR |
| Result count announced in a status region | Unit test on the region's text | Every PR |
| No axe violations in default, open, empty, error, disabled states | Storybook scan in a real browser | Every PR |
| Contrast and focus ring in light, dark, and forced colors | Storybook scan plus visual check | Every PR touching styles |
| Works with NVDA plus Chrome and VoiceOver plus Safari | Manual screen reader script | Before each release |
| Works at 400 percent zoom and with voice control | Manual check | Before 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.
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?
Do the cheap layers fully: eslint-plugin-jsx-a11y as errors, role-based queries in every component test, and an axe scan in the Playwright tests of the three or four main flows. That is a day or two of setup.
Add a 15-minute keyboard and VoiceOver or NVDA pass to the definition of done for any PR that changes UI, using a fixed written script.
Then pay for one expert audit with a few disabled testers on the main flow before a big launch or a big enterprise deal. Fix what they find in the shared components first, and add a test for each fix so it stays fixed.
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 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.
Decision Your app has 340 existing axe violations. How should CI treat them?
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.
| Criterion | Conformance level | Remarks and explanations |
|---|---|---|
| 1.1.1 Non-text Content (A) | Supports | All product images have text alternatives. Decorative images are hidden from assistive technology. |
| 1.4.3 Contrast (Minimum) (AA) | Partially Supports | Body 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 Supports | All 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 Applicable | The 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
| Level | What a good answer covers |
|---|---|
| Junior | Semantic HTML first; getByRole in tests; runs axe or Lighthouse; knows a score is not the whole story; can do a keyboard pass. |
| Mid-level | Names 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. |
| Senior | Designs 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
getByRoleis 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?
Lighthouse runs a subset of axe-core rules against one snapshot of one page state, and each audit is just pass or fail. It cannot tell whether focus moves into the payment dialog, whether a price change is announced, whether a custom dropdown works by keyboard, or whether names make sense. Its own list of manual checks is not part of the score.
I would reproduce the flow with a keyboard and with NVDA or VoiceOver, write down what is spoken at each step, and fix the blockers first. Then I would add a role-based unit test for each fix (focus lands in the dialog, the status region gets the new price) and an axe scan of the dialog and error states in Playwright, so the same bugs fail the build next time.
Question 2
How would you set up accessibility testing for a design system used by 40 product teams?
Make the common bugs impossible to ship from the shared layer. eslint-plugin-jsx-a11y as errors in the shared lint config. Every component has role-based tests for name, role, and states, plus keyboard tests taken from the WAI-ARIA pattern it implements. Every story state is scanned with axe in a real browser in CI, in each theme, and the build blocks on any violation.
Before each release, a manual keyboard and screen reader script on two or three screen reader and browser pairs, with the results noted in each component's docs. Then measure where teams build their own versions instead of using the shared ones, because those are where most product bugs will come from.
Question 3
What can automated accessibility tools not catch? Give examples.
Anything that needs judgment, interaction, or context: whether alt text is meaningful, whether link text makes sense on its own, whether focus order follows the layout, whether focus returns after a dialog closes, whether a live region speaks at the right time and only once, whether a custom widget's keyboard model works, and whether the flow is understandable when you can only hear it.
They also only see the states you render. A scan of a closed menu says nothing about the open menu. That is why I scan states rather than URLs and keep a short manual script for each feature.
Question 4
Which WCAG level would you target, and why not AAA?
WCAG 2.2 level AA, or 2.1 AA where a contract or law names it. AA is what the European Accessibility Act's standard, the US ADA Title II rule, and most procurement rules point to, and it removes the biggest everyday barriers such as low contrast, missing focus, and broken reflow.
The W3C does not recommend AAA as a target for whole sites, because some content cannot meet it, for example sign language for all video. I would still adopt individual AAA criteria where they are cheap and help users, like larger targets.
Question 5
An old app has 340 axe violations. How do you add accessibility checks to CI without blocking every team?
Record today's violations as a baseline keyed by rule and element, and fail the build only on new ones. Each fix removes entries, so the count can only go down. New code and the design system are held to zero from day one.
Then burn the baseline down by severity times reach: blockers on the busiest flows first, and shared components before single pages, because one fix there helps every screen. Give the baseline an owner and a date so it does not become permanent.
Practice it
These design problems lean on testing accessibility.
References
- Web Content Accessibility Guidelines (WCAG) 2.2 The standard itself, with every success criterion and level.
- Understanding conformance (W3C) What meeting a level means, and why AAA is not a whole-site target.
- axe-core on GitHub The rules engine, its result groups, and the list of rules with WCAG tags.
- Testing Library: About queries Query priority and the options for role queries.
- eslint-plugin-jsx-a11y Static accessibility rules for JSX.
- GDS: How do automated accessibility checkers compare? The UK government audit of 13 tools against 142 deliberate barriers.
- Deque: automated testing study (57 percent) Deque's 2021 study of automated coverage by issue volume, from its own audit data.
- Storybook: Accessibility tests The a11y addon, its result groups, and the test parameter that fails CI.
- Playwright: Accessibility testing Using @axe-core/playwright to scan pages in end-to-end tests.
- WebAIM Screen Reader User Survey #10 results Which screen readers and browsers people use.
- ADA.gov: fact sheet on the Title II web rule The 2024 rule, its WCAG 2.1 AA standard, and the 2026 extension of its compliance dates.
- ITI VPAT The template behind Accessibility Conformance Reports, with its editions.
Read next
- Semantic HTML firstUsing the HTML element that already means what you need, like
button,a,nav, andh2, so keyboards, screen readers, and voice control understand the page with no extra code. - Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- Screen readers and live regionsHow screen readers turn a page into speech or braille, how every control gets its accessible name and description, and how to announce changes like search results, saves, and errors with live regions.
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Unit and integration testsFast tests that check what users see and do: Testing Library queries, user-event, async UI without flakiness, mocking the network with MSW, and testing hooks, forms, and timers without coupling tests to how the code is built.
- End-to-end testsTests that drive a real browser through your real app like a user would: what to cover, how to keep them reliable, where test data comes from, and how to run them in CI.