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)
  • Correct ARIA for custom widgets: roles, names, and states. No role="button" without keyboard handlers and aria-pressed/expanded as 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 correct

Guardrails 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-a11y
    • jest-axe unit/integration tests
    • Playwright/Cypress with axe-core
  • Nightly:
    • pa11y-ci and 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/a11y

Same 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.log

Align 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:

  1. Week 1: add lint rules, PR template, Storybook addon; fix top 10 offenders.
  2. Week 2: wire jest-axe for components and Playwright @axe-core for 2–3 critical flows.
  3. Week 3: nightly pa11y-ci/Lighthouse; start storing artifacts; turn on merge gate in warn‑only mode.
  4. 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.

Related Resources

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.

Make WCAG 2.2 AA a hard gate without slowing delivery See how we turn policy into proofs

Related resources