Accessibility cannot be shipped as an article of faith - it has to be measured, and measurement splits into two layers that are often conflated. The first is automated: programs that read the markup and the rules. The second is manual: a person who walks through the interface the way a real user will. The temptation is to stop at the first - it is fast, cheap, and gives you a green check in CI. But the green check answers a different question.
Automated tools are excellent at what is machine-checkable: a missing alt on an image, a form field with no associated label, insufficient text contrast, duplicate ids, a button with no accessible name. The toolset is settled. eslint-plugin-jsx-a11y catches violations right in the editor as the code is written. axe-core is the rules engine most checks are built on. Lighthouse gives a summary score for a page. Playwright with axe runs the same rules in a real browser against the live render. An HTML validator catches invalid nesting that breaks the accessibility tree. This is cheap and mandatory hygiene.
That is where the ceiling of automation ends. No engine will tell you whether an alt is meaningful: alt=img_4832 passes the has-an-attribute check while telling a blind shopper nothing at all. A machine cannot judge whether the focus order is logical, whether a link leads where its text promises, or whether the task is even completable - placing an order from cart to confirmation. Estimates vary but agree in order of magnitude: automation covers roughly a third to a half of the WCAG criteria. The rest - meaning, context, and behavior - only a human sees.
So automation is wired into two points of the pipeline. The linter runs at authoring time and keeps a violation from reaching a commit. axe inside Playwright tests runs in CI and guards critical pages against regressions: if a serious violation appears on the checkout page, the build fails and nobody ships the breakage to production. Such a test is cheap to maintain and pays for itself with the first regression it catches.
Manual review starts with the keyboard, because it lays the structure bare. Put the mouse away and walk the scenario with Tab alone: every interactive element must take focus, a visible focus ring must be present, the traversal order must match the visual order, and there must be a way out of any component, with no traps. If the Buy button cannot be reached from the keyboard, then a share of shoppers will buy nothing, and no axe run will report it.
The keyboard does not exhaust the list. Zoom to 200-400% checks whether the layout falls apart and whether content is lost on magnification. A real screen reader - VoiceOver on macOS, NVDA on Windows - shows how a page sounds rather than how it looks. Reduced motion checks whether the interface honors the request to remove animation. Forced colors - whether it survives a forced high-contrast palette. It helps to keep these layers in front of you at once.
| Layer | Check | What it catches |
|---|---|---|
| Automation | linter + axe | syntax: alt, labels, contrast, ids |
| Keyboard | Tab only | focus, traversal order, traps |
| Zoom | 200-400% | overflow and lost content |
| Screen reader | VoiceOver, NVDA | sound, names, reading order |
| Motion | reduced motion | respect for the user's setting |
| Colors | forced colors | survival in a contrast palette |
A telling failure looks like this: the page scores 100 out of 100 in Lighthouse, the team celebrates, and a week later a complaint arrives - a blind user cannot finish payment because after the modal opens, focus stays beneath it, on the hidden page. Automation was right within its bounds and blind beyond them. The discipline is simple: automation in every commit and in CI as the mandatory minimum, a manual pass of the key scenarios before release, and never mistaking a green check for an interface that works for a human.
npm i -D @axe-core/playwright
import AxeBuilder from '@axe-core/playwright';
test('page has no serious a11y violations', async ({ page }) => {
await page.goto('/checkout');
const result = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag22aa'])
.analyze();
expect(result.violations).toEqual([]);
});