The Day-Before Audit That Blocked Release: Making WCAG 2.2 AA and ARIA Non‑Negotiable
Translate policy into guardrails, checks, and automated proofs—without slowing delivery or leaking regulated data.
Accessibility isn’t a feature. It’s a failing test you haven’t run yet.Back to all posts
The fire drill you’ve lived through
A fintech I worked with had a Friday greenlight. Thursday afternoon, the external audit flagged WCAG 2.2 AA failures: Focus Appearance (2.4.13) missing on custom buttons, Target Size Minimum (2.5.8) violated in dense table actions, and an Accessible Authentication (3.3.7) issue because CAPTCHA was the only path. No automated proofs, no CI gates, just a wall of Jira bugs. Release blocked. I’ve seen this fail across SaaS, healthcare, and gov: accessibility treated as polish instead of a non‑negotiable acceptance criterion.
Here’s what actually works: translate policy into guardrails, checks, and automated proofs. Make it faster to do the right thing than to ignore it.
Make WCAG 2.2 AA and ARIA part of your Definition of Done
Stop debating “done vs. done‑done.” Put the criteria in writing where devs live: tickets, PR templates, and CI. For every user story touching UI, acceptance criteria include:
- Meets WCAG 2.2 AA for changed scope: keyboard access, visible focus, contrast, semantics, and new 2.2 items:
- 2.4.13 Focus Appearance Minimum (custom focus rings, not
outline: none) - 2.5.7 Dragging Movements (offer click/keys alternative)
- 2.5.8 Target Size Minimum (≥ 24×24 CSS px or equivalent hit area)
- 3.3.7 Accessible Authentication (no cognitive test as the only method)
- 3.3.8 Redundant Entry (don’t re‑ask known info)
- 2.4.13 Focus Appearance Minimum (custom focus rings, not
- Correct ARIA for custom widgets: roles, names, and states. No
role="button"without keyboard handlers andaria-pressed/expandedas applicable. - No critical axe violations in changed components/pages.
- Keyboard‑only smoke passes: open/close modals, menus, and dialogs; focus is trapped logically and returned on close.
Example PR template snippet:
### Accessibility (WCAG 2.2 AA)
- [ ] No critical `axe` violations in changed parts
- [ ] Keyboard‑only navigation verified
- [ ] Visible focus per 2.4.13
- [ ] Targets ≥24×24 or equivalent hit area
- [ ] Dragging alternatives provided (2.5.7)
- [ ] ARIA roles/names/states correctGuardrails in the dev loop: lint, tokens, and Storybook that nags
Linting catches 80% before a browser ever opens.
{
"extends": ["react-app", "plugin:jsx-a11y/recommended"],
"plugins": ["jsx-a11y"],
"rules": {
"jsx-a11y/no-autofocus": "error",
"jsx-a11y/interactive-supports-focus": "error",
"jsx-a11y/anchor-is-valid": "error"
}
}Design tokens make contrast automatic instead of optional. Ship a token set that never drops below AA.
:root {
--color-bg: #0b0c0c; /* GOV.UK contrast baseline */
--color-text: #ffffff;
--color-accent: #1d70b8; /* meets AA on bg */
--focus-ring: 2px solid #ffdd00; /* high‑contrast focus */
}
.button:focus-visible { outline: var(--focus-ring); outline-offset: 2px; }
/* target size without breaking layout */
.icon-btn { position: relative; }
.icon-btn::after {
content: ""; position: absolute; inset: -6px; /* expands hit area to 24×24 */
}In Storybook, fail the story before it hits a branch.
// .storybook/main.ts
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
addons: ['@storybook/addon-a11y'],
};
export default config;
// .storybook/preview.ts
export const parameters = {
a11y: { element: '#root', manual: false },
};Add a simple axe snapshot test on stories during CI. If you’re using Chromatic or Loki, wire in @storybook/addon-a11y checks and treat critical issues as blockers, not comments.
CI checks that block merges, not people
You need two tiers: fast checks on PR, deep scans nightly.
- PR:
eslint-plugin-jsx-a11yjest-axeunit/integration tests- Playwright/Cypress with
axe-core
- Nightly:
pa11y-ciand Lighthouse across authenticated flows with synthetic data
Scripts:
{
"scripts": {
"lint:a11y": "eslint --ext .tsx,.ts src",
"test:a11y": "jest --selectProjects a11y",
"e2e:a11y": "playwright test --project=a11y",
"scan:pa11y": "pa11y-ci --json > artifacts/a11y/pa11y.json",
"scan:lighthouse": "lhci autorun --upload.target=filesystem"
}
}jest-axe example:
// src/components/Button.a11y.test.tsx
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import Button from './Button';
expect.extend(toHaveNoViolations);
test('button has no a11y violations', async () => {
const { container } = render(<Button>Save</Button>);
const results = await axe(container);
expect(results).toHaveNoViolations();
});Playwright with axe:
// tests/a11y.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('page a11y', async ({ page }) => {
await page.goto('/account');
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});GitHub Actions wiring:
name: a11y
on: [pull_request]
jobs:
pr-a11y:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npm run lint:a11y
- run: npm run test:a11y -- --reporters=default --json --outputFile=artifacts/a11y/jest.json
- run: npx playwright install --with-deps && npm run e2e:a11y
- uses: actions/upload-artifact@v4
with:
name: a11y-artifacts
path: artifacts/a11ySame pattern works in GitLab CI or Azure DevOps. Block merges on this job.
Automated proofs and policy‑as‑code gates
Auditors don’t want promises; they want receipts. Create machine‑readable proofs and enforce their presence.
- Store JSON artifacts under
artifacts/a11y/and attach them to the build. - Keep a retention bucket (S3/GCS) per release with hashes.
- Gate merges/releases via OPA/Rego: changed UI must include a passing artifact.
Rego example:
package a11y.gate
default allow = false
ui_change { some f; input.changed_files[f]; endswith(f, ".tsx") }
artifact_present { input.artifacts["a11y"].jest.passing == true }
no_critical { input.artifacts["a11y"].axe.critical == 0 }
allow { not ui_change } # no UI change, no gate
allow { ui_change; artifact_present; no_critical }Feed the policy with a simple metadata JSON your CI assembles:
{
"changed_files": ["src/components/Button.tsx"],
"artifacts": {
"a11y": {
"jest": { "passing": true },
"axe": { "critical": 0, "serious": 1 }
}
}
}Run conftest test ci-metadata.json -p policy/ and block if allow is false. Now you’ve got an auditable trail without a meeting.
ARIA patterns that pass audits (and keep users sane)
These patterns fail most often in SPAs. Use battle‑tested implementations.
Accessible disclosure button:
// AccordionHeader.tsx
import { useId, useState } from 'react';
export function Accordion() {
const [open, setOpen] = useState(false);
const panelId = useId();
return (
<div>
<button
aria-expanded={open}
aria-controls={panelId}
onClick={() => setOpen(!open)}
className="accordion-trigger"
>
Details
</button>
<div id={panelId} role="region" hidden={!open}>
<p>Panel content</p>
</div>
</div>
);
}Modal with focus trap and return focus:
// Modal.tsx (React)
import { useEffect, useRef } from 'react';
import focusTrap from 'focus-trap';
export function Modal({ onClose, children }: { onClose: () => void; children: React.ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const trap = focusTrap(ref.current!, { escapeDeactivates: true, returnFocusOnDeactivate: true });
trap.activate();
return () => trap.deactivate();
}, []);
return (
<div role="dialog" aria-modal="true" aria-label="Payment" ref={ref}>
<button onClick={onClose}>Close</button>
{children}
</div>
);
}Live updates without screen‑reader spam:
<div aria-live="polite" aria-atomic="true" id="status">Saved</div>Dragging alternative (2.5.7):
/* Provide reorder via buttons as alternative to drag */
<button aria-label="Move item up" onClick={moveUp}>▲</button>
<button aria-label="Move item down" onClick={moveDown}>▼</button>If you’re building from scratch, lean on react-aria, aria-practices, or the GOV.UK Design System components—don’t invent a combobox the night before the demo.
Regulated data: keep scanners local and your lawyers calm
You can be fast and compliant if you keep data in your control.
- Run scanners in CI or self‑hosted runners. No DOM snapshots to third‑party SaaS.
- Synthetic fixtures only in E2E; never mirror production PII/PHI/PCI.
- Redact logs before upload; treat accessibility artifacts like any build artifact.
- Short‑lived test accounts with masked identifiers.
Quick redaction step for CI logs:
# redact emails and PAN-like numbers from playwright logs
sed -E 's/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/[REDACTED_EMAIL]/g; s/\b[0-9]{13,19}\b/[REDACTED_PAN]/g' \
playwright-report/output.log > artifacts/sanitized.logAlign this with your GDPR/HIPAA data maps and DLP policies, and you won’t spend a week arguing with Security about a Lighthouse screenshot.
Metrics, rollout, and not boiling the ocean
Pick SLOs, measure, and iterate.
- SLO: 0 critical axe violations on
main; ≤ 5 serious; trend improves weekly. - KPIs: violation count per page, time‑to‑fix, coverage of a11y tests, % stories with a11y AC checked.
- Dashboards: push
pa11y/axe JSON into your ELK or Grafana Loki; chart over time.
30‑day rollout that’s actually doable:
- Week 1: add lint rules, PR template, Storybook addon; fix top 10 offenders.
- Week 2: wire
jest-axefor components and Playwright@axe-corefor 2–3 critical flows. - Week 3: nightly
pa11y-ci/Lighthouse; start storing artifacts; turn on merge gate in warn‑only mode. - Week 4: flip the gate to block on critical; publish SLO dashboard; schedule quarterly manual keyboard/a11y sweeps.
Accessibility isn’t a feature. It’s a failing test you haven’t run yet.
If you need a sparring partner to implement this without grinding your pipeline to dust, GitPlumbers has done this in fintech, health tech, and gov. We’ll help you move fast and ship proofs your auditors actually accept.
Key takeaways
- Bake WCAG 2.2 AA and ARIA into Definition of Done, not a last‑minute QA task.
- Translate policy into guardrails: lint rules, Storybook checks, CI tests, and policy‑as‑code gates.
- Produce automated proofs (JSON artifacts) to satisfy audits without slowing teams.
- Use two‑tier checks: fast PR lint/tests and nightly deep scans.
- Protect regulated data by scanning locally/CI, using synthetic fixtures, and redacting logs.
Implementation checklist
- Add `eslint-plugin-jsx-a11y` and block merges on errors.
- Enable `@storybook/addon-a11y` and fail stories with critical issues.
- Write `jest-axe` or Playwright/Cypress `axe` tests for changed pages/components.
- Run `pa11y-ci`/Lighthouse in CI and upload JSON artifacts.
- Use OPA/Rego to enforce artifact presence and zero critical violations before merge/release.
- Adopt design tokens that guarantee AA contrast by default.
- Codify WCAG 2.2 AA acceptance criteria in PR templates and DoD.
- Keep scans local/CI; never send DOM snapshots with PII/PHI to third-party SaaS.
Questions we hear from teams
- Can we automate 100% of WCAG 2.2 AA?
- No. Automation catches a large chunk (name/role/value, contrast, ARIA misuse, focus traps), but you still need manual checks for keyboard flows, visual focus quality, and screen reader experience. Treat automation as the gate and manual as periodic audits.
- We’re a heavy SPA—won’t dynamic content break scanners?
- Use Playwright/Cypress to drive real states before running axe. Wait for idle/networkquiet, open modals/menus, and analyze specific containers. Test the state your users see, not just the initial DOM.
- Will this slow our teams down?
- After the first week, it speeds you up. Lint rules and Storybook addons provide instant feedback; CI fails early instead of during release. Two‑tier checks keep PRs fast and deep scans off the happy path.
- How do we handle third‑party widgets and iframes?
- Sandbox them. Wrap with accessible affordances, provide keyboard alternatives, and require vendors to deliver their own a11y scan artifacts. If they can’t, gate usage behind a risk exception with a deprecation date.
Ready to modernize your codebase?
Let GitPlumbers help you transform AI-generated chaos into clean, scalable applications.
