Stop Hand-Waving Accessibility: How We Made WCAG 2.2 AA + ARIA Non‑Negotiable in CI
If your “accessibility” plan is a Jira label and a quarterly audit, you’re already in trouble. Here’s how to turn WCAG 2.2 AA and ARIA into guardrails, checks, and automated proofs that ship fast—even under HIPAA/PCI/GDPR constraints.
Accessibility isn’t a ticket. It’s a property of the system—and your pipeline is the system.Back to all posts
The audit that blew up sprint planning
I’ve lived this twice: a healthcare fintech, a last-minute VP mandate, and an external audit that surfaced 1,600+ axe violations across core flows. Three sprints evaporated into "fix contrast" tickets that still broke keyboard nav. It wasn’t a tools problem—it was process. Accessibility sat outside Definition of Done, buried under a Jira label. When we turned WCAG 2.2 AA and ARIA into non-negotiable acceptance criteria with automated proofs, the “a11y fire drills” stopped, velocity went up, and audits turned into paperwork, not panic.
Make WCAG 2.2 AA and ARIA part of "done", not a suggestion
If your team treats accessibility like a post-merge chore, it will get deferred until the audit. What works is making it part of the SDLC contract:
- Definition of Ready: Designs include focus states, target sizes, drag alternatives, and role/label map.
- Definition of Done: No critical/serious axe violations. Keyboard-only passes. Screen reader announces correctly. Evidence stored.
- Explicit WCAG 2.2 AA deltas you probably missed:
- 2.4.11 Focus Appearance (Minimum): visible focus with contrast and 3px-ish thickness.
- 2.5.7 Dragging Movements: must have non-drag alternative.
- 2.5.8 Target Size (Minimum): 24×24 px unless exceptions apply.
- 3.2.6 Consistent Help: same assistance in consistent location across pages.
Here’s how we codify it in-repo as policy-as-code so it’s not just Confluence:
# accessibility-policy.yaml
wcagVersion: "2.2"
level: "AA"
nonNegotiable:
- "2.4.11 Focus Appearance (Minimum)"
- "2.5.7 Dragging Movements"
- "2.5.8 Target Size (Minimum)"
- "3.2.6 Consistent Help"
guardrails:
designTokens:
focusRingWidth: ">=3px"
minTargetSize: ">=24px"
components:
- "Use <button>, not <div role='button'>"
- "Dialogs use <dialog> with aria-modal='true' and focus trap"
checks:
lint:
- "eslint-plugin-jsx-a11y"
test:
- "jest-axe for components"
e2e:
- "cypress-axe on critical journeys"
gates:
- "Block merge if critical/serious axe violations > 0"
evidence:
- "Store SARIF/JSON artifacts for 180 days"Turn policy into guardrails in code (design system first)
I’ve seen a dozen teams try to lint their way to accessibility. It doesn’t work. Start at the design system and product surfaces everybody touches.
- Design tokens enforce WCAG 2.2
:root {
--focus-ring-color: #1e90ff;
--focus-ring-offset: 2px;
--focus-ring-width: 3px; /* Meets 2.4.11 Focus Appearance */
--tap-target-min: 24px; /* Meets 2.5.8 Target Size */
}
.btn { min-width: var(--tap-target-min); min-height: var(--tap-target-min); }
.btn:focus-visible {
outline: var(--focus-ring-width) solid var(--focus-ring-color);
outline-offset: var(--focus-ring-offset);
}- Ship a11y-first primitives (React example). Don’t let teams reinvent dialog or combobox.
// Button.tsx
import React from 'react';
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & { loading?: boolean };
export function Button({ loading, children, ...rest }: ButtonProps) {
return (
<button {...rest} aria-busy={loading || undefined} className="btn">
{loading ? <Spinner role="status" aria-label="Loading" /> : null}
<span>{children}</span>
</button>
);
}- ARIA patterns that always bite teams
- Dialogs:
aria-modal="true", initial focus within, return focus on close. - Combobox: use ARIA 1.2 pattern with
role="combobox",aria-expanded,aria-controls,aria-activedescendant. - Live regions:
aria-live="polite"for toasts; don’t spam.
- Dialogs:
Lock these in Storybook with @storybook/addon-a11y so every variant is scanned.
// .storybook/preview.ts
import { withA11y } from '@storybook/addon-a11y';
export const decorators = [withA11y];
export const parameters = {
a11y: { element: '#root', manual: false, config: { rules: [{ id: 'color-contrast', enabled: true }] } }
};Checks that fail fast (local, pre-commit, PR)
Developers won’t read a policy PDF while in flow. Make the right thing the easy thing.
- Lint with
eslint-plugin-jsx-a11y@6and make it red by default.
{
"plugins": ["jsx-a11y", "testing-library"],
"extends": ["plugin:jsx-a11y/recommended"],
"rules": {
"jsx-a11y/anchor-is-valid": ["error", { "aspects": ["noHref", "invalidHref"] }],
"jsx-a11y/no-autofocus": "error",
"jsx-a11y/aria-props": "error",
"jsx-a11y/no-noninteractive-element-interactions": "error"
}
}- Unit tests with
jest-axefor components.
// Button.test.tsx
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import React from 'react';
import { Button } from './Button';
expect.extend(toHaveNoViolations);
it('Button is accessible', async () => {
const { container } = render(<Button>Save</Button>);
expect(await axe(container)).toHaveNoViolations();
});- E2E with
cypress-axeon critical flows.
// cypress/e2e/a11y.cy.ts
import 'cypress-axe';
describe('A11y', () => {
it('has no WCAG 2.2 AA violations on key pages', () => {
const routes = ['/', '/signup', '/settings'];
routes.forEach((r) => {
cy.visit(r);
cy.injectAxe();
cy.configureAxe({ rules: [
{ id: 'color-contrast', enabled: true },
{ id: 'focus-order-semantics', enabled: true }
]});
cy.checkA11y(null, { includedImpacts: ['critical', 'serious'] }, (violations) => {
cy.writeFile(`reports/axe-${r.replace(/\W/g, '_')}.json`, violations);
});
});
});
});- Pre-commit: make it cheap to do the right thing.
# .husky/pre-commit
npm run lint:a11y && npm run test:unitAutomated proofs in CI/CD (and storing the evidence)
Auditors don’t want anecdotes. They want repeatable, timestamped evidence. We run scans in CI, block merges, and keep artifacts.
# .github/workflows/a11y.yml
name: a11y-ci
on: { pull_request: {} }
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npm run build
- run: npm run start:test &
- name: Wait for app
run: npx wait-on http://localhost:3000
- name: Run Cypress + axe
run: npx cypress run --config baseUrl=http://localhost:3000
- name: Pa11y CI
run: npx pa11y-ci --config pa11yci.json
- name: Lighthouse CI
run: npx lhci autorun --collect.url=http://localhost:3000
- name: Convert axe to SARIF
run: npx axe-sarif-converter "reports/**/*.json" > reports/axe.sarif
- name: Upload SARIF
uses: github/codeql-action/upload-sarif@v3
with: { sarif_file: reports/axe.sarif }
- name: Save artifacts
uses: actions/upload-artifact@v4
with:
name: a11y-reports
path: reports/**Gates that matter
- Block merge if any critical/serious axe violations.
- Track thresholds in code (not in a slide deck).
- Keep artifacts for 180 days; link them to releases.
Metrics leadership actually cares about
- Gate pass rate per PR.
- Violation count trend (critical/serious) per week.
- % component coverage with
jest-axe. - Mean time to remediate a11y blockers (<5 days target).
Ship fast under regulated-data constraints
“Security said we can’t run scanners” is usually code for “we don’t know how to run them safely.” Here’s what we do in HIPAA/PCI/GDPR shops:
- Keep scanning in your VPC: self-hosted runners; no DOM or screenshots leave the network.
- Use synthetic data: seed fixtures; mask PII in logs and artifacts.
- Scrub artifacts: force PNG scrubbing and redact DOM snapshots.
- Short retention: 30–180 day artifact TTL aligned to policy.
- Avoid SaaS calls: axe, Pa11y, Lighthouse run locally; publish SARIF to GitHub Advanced Security (data stays in repo).
Example env hardening for CI:
# .env.ci (example)
A11Y_SENSITIVE_MODE=true
ARTIFACT_TTL_DAYS=90And Cypress config pattern to redact:
// cypress/support/index.ts
if (Cypress.env('A11Y_SENSITIVE_MODE')) {
Cypress.Screenshot.defaults({ onAfterScreenshot() { /* scrub */ } });
Cypress.on('log:added', (attrs, log) => { /* redact emails/PHI */ });
}If Compliance pushes back, we loop them in early with a proof-of-concept run on synthetic data and show them the stored proofs. 9/10 times that unblocks everything.
What "good" looks like in 90 days
We’ve rolled this out in a B2B health platform and a consumer fintech; the pattern holds.
- Weeks 1–2: Tokens and components shipped;
eslint-plugin-jsx-a11yenforced; Storybook a11y on. - Weeks 3–4:
jest-axeadded to core components; Cypress + axe on the top 5 flows; CI storing SARIF. - Weeks 5–8: CI gate blocks merges; dashboards show violation burn-down; engineers trained on ARIA patterns.
- Weeks 9–12: Auditable trail in place; a11y defects per 1k LOC down 60–80%; zero last-minute audit surprises.
Lessons learned:
- Don’t start with a big-bang audit; start with guardrails and gates.
- Ship a small set of hardened components before scanning everything.
- Make failures developer-friendly (inline annotations, SARIF to PR).
- Treat this like SRE: SLOs for a11y, incident reviews for regressions.
Try this acceptance criteria template on your next PR
- Meets WCAG 2.2 AA criteria including 2.4.11, 2.5.7, 2.5.8, 3.2.6.
- Keyboard-only completes flow; visible focus states on interactive elements.
- Screen reader announces role, name, state; live updates are polite.
- No critical/serious axe violations; unit/E2E a11y tests updated.
- Evidence attached: SARIF + JSON artifacts linked to PR.
If your current setup can’t pass that, we should talk. GitPlumbers comes in, builds the guardrails, wires the checks, and leaves you with a boring, repeatable pipeline. I’ve seen the “just run an audit” approach fail every time; this is what actually works.
Key takeaways
- Accessibility is an engineering quality, not a quarterly audit. Bake WCAG 2.2 AA/ARIA into Definition of Done.
- Translate policy into code: tokens, components, linters, tests, and CI gates with stored proofs.
- Target WCAG 2.2 AA deltas explicitly: Focus Appearance (2.4.11), Dragging Movements (2.5.7), Target Size (2.5.8), Consistent Help (3.2.6).
- Use local fail-fast checks and PR gates for velocity; store SARIF/JSON artifacts for auditors.
- For regulated data, run scanners inside your VPC, scrub artifacts, and seed synthetic data.
Implementation checklist
- Add `eslint-plugin-jsx-a11y` and fail PRs on violations.
- Ship design tokens for focus ring and target size; enforce via CSS utilities.
- Adopt a11y-first components (e.g., Dialog, Combobox) with ARIA 1.2 patterns.
- Run `jest-axe` in unit tests and `cypress-axe` in E2E on critical flows.
- Publish SARIF reports and store artifacts 180 days for audit trails.
- Block merge on critical/serious axe violations; track burn-down.
- Use synthetic data and on-prem runners to respect HIPAA/PCI/GDPR.
- Codify DoD/DoR in repo (policy-as-code) and verify in CI.
Questions we hear from teams
- What minimum toolchain do I need to enforce WCAG 2.2 AA in CI?
- Lint with eslint-plugin-jsx-a11y, unit test with jest-axe, E2E with cypress-axe or Playwright + axe, and run Pa11y/Lighthouse for page-level checks. Convert to SARIF and upload to your PR for inline annotations.
- How do we handle WCAG 2.2’s new focus and target size rules?
- Ship design tokens for focus ring width/contrast and target size. Enforce via CSS utilities and component props. Add visual regression tests for focus-visible if you can.
- Won’t this slow us down?
- It speeds you up. Fail-fast locally, block only on critical/serious issues in PR. Teams we’ve worked with reduced post-merge a11y bugs by 60–80% in two quarters.
- We’re HIPAA/PCI. Can we still store artifacts?
- Yes—store them in your VPC with short TTL and PII-scrubbing. Avoid SaaS scanners and keep SARIF in your repo’s security tab. Use self-hosted runners.
- We have a legacy UI and AI-generated “vibe code.” Where do we start?
- Start at the design system: ship accessible Button, Link, Dialog, Combobox, and FormField. Then run component-level jest-axe and E2E on your top 5 flows. GitPlumbers can help with vibe code cleanup and code rescue to get you above the bar fast.
Ready to modernize your codebase?
Let GitPlumbers help you transform AI-generated chaos into clean, scalable applications.
