The Week Legal Called: Operationalizing WCAG 2.2 AA + ARIA as Non‑Negotiable Acceptance Criteria

Turn accessibility from a best‑effort into a release gate with automated guardrails, concrete proofs, and zero drama around regulated data.

Ship a policy, get a PDF. Ship a gate, get a result.
Back to all posts

The moment it got real

A retailer called us after a demand letter landed. Their SPA looked slick, but keyboard focus disappeared in modals, form errors weren’t announced, and auth used a pure CAPTCHA. Classic “we’ll fix it later.” Later arrived with a clock and outside counsel. We turned policy into code, made accessibility a gate, and shipped without lighting the org on fire.

If you’ve been burned by consultants waving PDFs, here’s the playbook we actually use to operationalize WCAG 2.2 AA and ARIA—as non‑negotiable acceptance criteria—without killing delivery speed or mishandling regulated data.

Make it part of Definition of Done, not a post‑launch cleanup

Policies don’t change behavior, gates do. Encode WCAG 2.2 AA and ARIA validity in your DoD and PR template.

  • Name specific 2.2 criteria that trip teams: 2.4.11 Focus Appearance, 2.5.7 Dragging Movements, 2.5.8 Target Size (Minimum), 3.2.6 Consistent Help, 3.3.7 Redundant Entry, 3.3.8 Accessible Authentication.
  • Scope: new UI and touched UI must meet 2.2 AA; regressions are blockers.
  • Evidence: automated scans pass, keyboard/screen reader checks documented, components used (no bespoke widgets).

Example PR checklist:

- [ ] Meets WCAG 2.2 AA: Focus appearance, target size, dragging alternative, redundant entry, accessible authentication
- [ ] Keyboard verified: logical tab order; Escape closes modals; arrow keys for menus
- [ ] axe: no critical/serious violations; ARIA roles/states valid
- [ ] Name/role/value exposed for all interactive controls
- [ ] Component library used (no custom comboboxes)

Ship a policy, get a PDF. Ship a gate, get a result.

Translate policy into guardrails: components, linting, and design tokens

What actually works is shifting left with hardened components and static checks.

  • Component library hardening (React, Vue, Web, doesn’t matter):
    • Buttons, links, dialog, tooltip, combobox, tabs with correct name/role/value, keyboard behavior, and visible focus. Use react-aria, radix-ui, or your hardened internal kit.
    • Focus rings via tokens that meet 2.2’s focus appearance. Don’t rely on default blue outlines.
// components/Toggle.tsx
import React from 'react';

type ToggleProps = { on: boolean; onChange: (v: boolean) => void; label: string };
export function Toggle({ on, onChange, label }: ToggleProps) {
  return (
    <button
      type="button"
      aria-pressed={on}
      onClick={() => onChange(!on)}
      onKeyDown={(e) => {
        if (e.key === 'Enter' || e.key === ' ') {
          e.preventDefault();
          onChange(!on);
        }
      }}
      className="focus:outline-2 focus:outline-offset-2 focus:outline-blue-600 min-w-[24px] min-h-[24px]"
    >
      <span aria-hidden>{on ? 'On' : 'Off'}</span>
      <span className="sr-only">{label}</span>
    </button>
  );
}
/* tokens.css */
:root {
  --focus-ring-color: #2563eb;
  --focus-ring-offset: 2px;
  --focus-ring-width: 2px;
}
:focus-visible {
  outline: var(--focus-ring-width) solid var(--focus-ring-color);
  outline-offset: var(--focus-ring-offset);
}
  • Lint for a11y during local dev:
// .eslintrc.json
{
  "extends": ["plugin:jsx-a11y/recommended"],
  "plugins": ["jsx-a11y"],
  "rules": {
    "jsx-a11y/anchor-is-valid": ["error", { "components": ["Link"], "specialLink": ["to"]}],
    "jsx-a11y/no-autofocus": ["error"],
    "jsx-a11y/no-noninteractive-element-interactions": ["error"]
  }
}
  • Storybook guardrails:
// .storybook/main.ts
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
  addons: ['@storybook/addon-a11y', '@storybook/addon-interactions'],
};
export default config;

Run addon‑a11y and catch issues on the component before it hits a page.

Automated checks and proofs in CI/CD

You need repeatable, exportable evidence. We wire multiple layers because no single tool catches everything.

  • Unit-level: jest-axe on components.
// __tests__/a11y.button.test.tsx
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import React from 'react';
import { PrimaryButton } from '../components/PrimaryButton';

expect.extend(toHaveNoViolations);

test('PrimaryButton is accessible', async () => {
  const { container } = render(<PrimaryButton label="Save" />);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});
  • E2E flows: cypress-axe (or Playwright + axe-core).
// cypress/e2e/checkout.a11y.cy.ts
import 'cypress-axe';

describe('Checkout a11y', () => {
  it('has no critical/serious violations', () => {
    cy.visit('/checkout');
    cy.injectAxe();
    cy.checkA11y(null, { includedImpacts: ['critical', 'serious'] });
  });
});
  • Page-level audits: Pa11y and Lighthouse CI for scoring and regression budgets.
// .pa11yci.json
{
  "defaults": { "wait": 500, "timeout": 30000, "standard": "WCAG2AA" },
  "urls": [
    "http://localhost:4173/",
    "http://localhost:4173/checkout"
  ]
}
// lighthouserc.json
{
  "ci": {
    "collect": {
      "url": [
        "http://localhost:4173/",
        "http://localhost:4173/checkout"
      ],
      "numberOfRuns": 2,
      "settings": { "emulatedFormFactor": "desktop" }
    },
    "assert": {
      "assertions": {
        "categories:accessibility": ["error", { "minScore": 0.95 }]
      }
    }
  }
}
  • Pipeline example (GitHub Actions; mirror in GitLab CI easily):
# .github/workflows/a11y-ci.yml
name: a11y-ci
on: [pull_request]
jobs:
  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 build
      - run: npm run start -- --port 4173 & npx wait-on http://localhost:4173
      - run: npx jest --runInBand --testPathPattern=a11y
      - run: npx pa11y-ci --config .pa11yci.json
      - run: npx @lhci/cli autorun --config=./lighthouserc.json
      - name: Upload reports
        uses: actions/upload-artifact@v4
        with:
          name: a11y-reports
          path: |
            ./**/pa11y*.json
            ./**/lighthouse-*.html

Set the bar: block PRs on axe critical/serious, and on Lighthouse accessibility < 0.95 for scoped pages.

Balance regulated-data constraints with delivery speed

Security will (rightly) block SaaS scanners scraping PII. You can still automate safely.

  • Self-host everything: axe-core, pa11y, @lhci/cli, cypress-axe run locally in CI. No third‑party exfiltration.
  • Use synthetic data in staging with CI-only tenants; disable production scanning except for truly public routes.
  • Block egress for scanner pods/agents in your CI namespace.
# k8s NetworkPolicy: deny egress from a11y runners except internal registry
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: a11y-runner-egress
  namespace: ci
spec:
  podSelector:
    matchLabels:
      app: a11y-runner
  policyTypes: ["Egress"]
  egress:
    - to:
        - ipBlock: { cidr: 10.0.0.0/16 }
  • Scrub artifacts: store HTML/JSON reports in S3/GCS with lifecycle rules; redact dynamic PII from logs.
  • Headless browsers: disable screenshots or mask content if your policies require it.
  • Privacy review: document data flows for the a11y pipeline once; reuse in audits.

This keeps risk low without slowing down merges.

Governance that doesn’t suck: SLOs, gating, triage

Treat accessibility like reliability.

  • SLOs:
    • 0 open “critical” a11y violations > 48h.
    • “Serious” issues MTTR < 14 days.
    • Lighthouse accessibility score p95 ≥ 0.95 on core flows.
  • Gating:
    1. Week 1–2: warn only, publish dashboard.
    2. Week 3: block on critical.
    3. Week 4: block on critical + serious; score budget enforced.
  • Triage:
    • Route violations to owning team via labels (e.g., a11y:modal), not a siloed “a11y team.”
    • Track fix rate and debt burn-down in the same board as feature work.

Accessibility debt is just tech debt with legal exposure. Manage it with the same discipline you use for SLOs and error budgets.

A 30‑day rollout plan that actually ships

You don’t need a six‑month program to get real wins.

  1. Baseline (Days 1–5)
    • Run Pa11y/Lighthouse across top 20 routes; export reports.
    • Identify 3–5 repeat offenders (focus rings, dialog, forms, target size).
  2. Component hardening (Days 6–15)
    • Fix focus tokens, dialog/combobox primitives, form error patterns (use aria-invalid, aria-describedby).
    • Add jest-axe to component tests; enable eslint-plugin-jsx-a11y.
  3. CI wiring (Days 10–20)
    • Add cypress-axe to 2–3 critical flows; hook Pa11y + Lighthouse CI.
    • Publish artifacts; stand up a read‑only dashboard (LHCI server or simple static site).
  4. Gating ramp (Days 21–30)
    • Start warning on PRs; fix low‑hanging fruit.
    • Flip to blocking on critical/serious + score budget for scoped routes.

Expect 60–80% violation reduction in 4–6 weeks when you fix patterns at the component level.

What we learned (and where GitPlumbers helps)

  • I’ve seen teams chase violations one page at a time for months. It fails. Fix primitives and you fix the app.
  • Don’t bikeshed tooling. Axe + Pa11y + Lighthouse cover 90% of automation needs; the rest is manual checks.
  • Make the DoD explicit and visible in PRs. Engineers follow what breaks builds.
  • Partner early with Security. Show them the self‑hosted, no‑egress architecture and synthetic datasets.

GitPlumbers comes in when you need to harden components, wire pipelines, and negotiate sane gates with Security and Legal. We leave you with dashboards, configs, and a team that knows how to keep them green.

Related Resources

Key takeaways

  • Accessibility is a release gate, not a post‑launch chore. Bake WCAG 2.2 AA + ARIA into your Definition of Done and PR checks.
  • Translate policy into code: lint rules, hardened components, Storybook a11y, and CI scanners (axe, Pa11y, Lighthouse CI).
  • Automate proofs: store scan reports and thresholds so audits aren’t fire drills.
  • Move fast without leaking data: self‑host scanners, block egress, use synthetic datasets.
  • Govern with SLOs and gating: block on critical/serious violations and measure MTTR for a11y defects.
  • Roll out in 30 days with a baseline audit, component hardening, CI wiring, and staged gating.

Implementation checklist

  • Adopt a DoD that names WCAG 2.2 AA success criteria and ARIA validity.
  • Harden your component library with focus, roles, and keyboard behavior baked in.
  • Enable eslint-plugin-jsx-a11y and Storybook addon-a11y.
  • Add jest-axe, cypress-axe/Playwright+axe, Pa11y, and Lighthouse CI to pipelines.
  • Store reports as artifacts; publish a dashboard with thresholds.
  • Restrict network egress for scanners; seed staging with synthetic data.
  • Create a11y SLOs and triage rules; block on critical/serious findings.

Questions we hear from teams

Does WCAG 2.2 AA apply to SPAs?
Yes. WCAG is technology‑agnostic. SPAs must expose correct name/role/value, manage focus on route changes and dialogs, and support keyboard interactions. Tools like react‑aria and router focus management helpers make this tractable.
Do we still need manual audits if we automate axe/Pa11y/Lighthouse?
Yes, but less often. Automation catches structural issues at scale; manual reviews validate semantics, complex interactions (drag alternatives), focus order, and screen reader announcements. Use automation to keep the floor high, then schedule targeted human audits.
How do we handle third‑party widgets?
Prefer vendors with WCAG 2.2 AA statements and testable demos. Sandbox them behind adapters; if a widget fails critical checks, block it or isolate it to non‑critical paths and document the risk. Contractually require accessibility fixes with SLAs.
What about native mobile?
Different toolchain, same principles. Use platform accessibility APIs, lint with Android Lint/SwiftLint rules, and automate with XCTest + axe (where supported) or platform equivalents. Track SLOs and gate on critical issues in CI just like web.
Can we keep velocity with these gates?
Yes. Harden components so most engineers never think about ARIA. Run fast local checks (eslint/jest-axe), parallelize CI scans, and ramp gating over a few weeks. Teams speed up once the rules are encoded in the library and pipeline.

Ready to modernize your codebase?

Let GitPlumbers help you transform AI-generated chaos into clean, scalable applications.

Get a pragmatic accessibility pipeline review See how we fix pipelines without slowing delivery

Related resources