Resource library

QA How-To

How to Add accessibility checks to CI (2026)

Learn how to add accessibility checks to CI with axe-core, Playwright, GitHub Actions, severity policies, reports, and maintainable WCAG gates for 2026 teams.

18 min read | 2,419 words

TL;DR

To add accessibility checks to CI, run axe-core via Playwright on critical routes, fail on critical/serious WCAG violations, upload reports always, and expand with lint plus keyboard smokes after the gate is trusted.

Key Takeaways

  • Run axe-core in a real browser on pull requests, not only as a yearly audit.
  • Fail CI on critical and serious impacts first to protect signal.
  • Cover authenticated product routes, not just marketing pages.
  • Upload HTML and JSON artifacts on every outcome with if: always().
  • Layer eslint-plugin-jsx-a11y, component scans, and full-page axe.
  • Time-box exclusions for third parties and disableRules with owners.
  • Tier PR smoke versus nightly deep scans to control runtime.

If you need to learn how to add accessibility checks to CI, start by treating accessibility as a quality gate, not a one-off audit. Wire automated WCAG checks into every pull request so violations fail fast, attach evidence, and leave a clear ownership trail for engineers and release managers.

This guide walks through a production-minded path for 2026: pick the right layer of checks, integrate axe-core with Playwright, fail CI on serious impact, publish reports, and keep the suite maintainable as the product grows. You will finish with a GitHub Actions workflow you can copy and extend.

TL;DR

Decision Recommended approach Reason
Primary engine axe-core via Playwright Mature rules, WCAG mapping, stable CI API
When to run Pull requests + main Catches regressions before merge
Failure policy Fail on critical and serious Blocks real barriers without noise floods
Evidence HTML + JSON artifacts Supports triage and history
Scope Key routes first Fast feedback, then expand

Ship a thin vertical slice: one authenticated flow, one public form, axe scan, fail on serious impact, upload the report. Only then add multi-page crawl, keyboard smoke, and custom rules.

1. Why how to add accessibility checks to CI matters in 2026

Accessibility debt compounds. Visual regressions and API failures get attention because pipelines already fail on them. Accessibility often stays manual until a legal review or customer complaint arrives. Automated checks in continuous integration change that dynamic: every commit is evaluated against a known rule set, and the result is visible beside unit and e2e status.

Automated checks do not replace human review. They catch missing labels, low contrast, empty buttons, broken ARIA, and document structure issues that scrapers and rule engines handle well. They do not judge whether a flow is understandable, whether error messages help users complete tasks, or whether motion and cognitive load are appropriate. Plan both: CI for continuous regression detection, and scheduled manual audits for experience quality.

From a release perspective, accessibility checks reduce late-stage surprises. A design system change that drops aria-label on an icon button should fail the same way a broken selector fails. That requires deterministic runs, stable environments, and a clear severity policy so the team trusts the gate.

2. Choose the right accessibility stack for CI

Several tools can power accessibility gates. Pick based on your stack, language, and how deeply you need browser context.

Approach Best for CI notes
@axe-core/playwright Playwright TS/JS e2e suites Runs in real browser context after navigation
axe-core + Selenium Java/Python Selenium estates Inject script after page load
Cypress + axe plugin Cypress-first teams Fits existing Cypress jobs
Pa11y / pa11y-ci URL lists and static routes Simple, less app-state control
Lighthouse accessibility category Broad audits with perf/SEO Heavier, less precise for unit-like gates
eslint-plugin-jsx-a11y Component source Fast, no browser, limited runtime ARIA

For most product teams already on Playwright, @axe-core/playwright is the default. It scans the live DOM after your app hydrates, respects frames when configured, and produces machine-readable violations you can fail on. Pair it with source-level lint rules so pure markup issues never reach the browser.

If you already invested in accessibility testing checklist processes, map those manual checks into automated rules where possible and leave the rest as exploratory charters.

3. Install dependencies and prove a local baseline

Pin versions through your lockfile. Illustrative package set for a Node 22 Playwright project:

npm install -D @playwright/test @axe-core/playwright
npx playwright install --with-deps chromium

Create a minimal test that navigates and analyzes:

// tests/a11y/home.a11y.spec.ts
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

test.describe("accessibility", () => {
  test("home page has no critical or serious axe violations", async ({ page }) => {
    await page.goto("/");

    const results = await new AxeBuilder({ page })
      .withTags(["wcag2a", "wcag2aa", "wcag21aa", "wcag22aa"])
      .analyze();

    const blocking = results.violations.filter((v) =>
      ["critical", "serious"].includes(v.impact ?? "")
    );

    expect(blocking, JSON.stringify(blocking, null, 2)).toEqual([]);
  });
});

Run locally before CI:

npx playwright test tests/a11y

You should see either a clean pass or a structured violation list. Fix or temporarily scope rules only with an explicit ticket, never with silent disables in shared helpers.

4. Design a severity and tagging policy

A common failure mode is failing the pipeline on every minor violation. Teams then disable the job. Instead, define a written policy:

  • Critical / serious: fail CI on mainline branches and pull requests.
  • Moderate: fail on main only, or open issues automatically without blocking merge (team choice).
  • Minor / incomplete: report only, track trend weekly.

Use axe tags aligned to WCAG levels your product claims. Many teams target WCAG 2.2 AA for customer-facing surfaces. Do not invent tags; stick to axe-core documented tags such as wcag2a, wcag2aa, wcag21aa, and wcag22aa.

Document exclusions. Marketing microsites, third-party widgets, and experimental branches may need different rules. Prefer include / exclude selectors and route-level allowlists over global rule disables.

const results = await new AxeBuilder({ page })
  .include("main")
  .exclude("#third-party-chat-widget")
  .disableRules(["color-contrast"]) // only with documented exception ID
  .analyze();

Every disableRules entry needs an owner, expiry date, and linked issue. Review exceptions monthly.

5. Cover routes, states, and authentication

Scanning only the marketing home page is not enough. Build a route matrix:

  1. Public landing and legal pages.
  2. Authenticated dashboard shell.
  3. Primary create/edit forms.
  4. Error and empty states.
  5. High-traffic modals and drawers.

Prefer reusing existing e2e setup for auth. Seed a test user, log in through your supported API or UI helper, then scan. Avoid hard-coding production credentials.

test("settings form accessibility", async ({ page }) => {
  await page.goto("/login");
  await page.getByLabel("Email").fill(process.env.A11Y_USER!);
  await page.getByLabel("Password").fill(process.env.A11Y_PASSWORD!);
  await page.getByRole("button", { name: "Sign in" }).click();
  await page.waitForURL("**/settings");

  const results = await new AxeBuilder({ page }).analyze();
  const blocking = results.violations.filter((v) =>
    ["critical", "serious"].includes(v.impact ?? "")
  );
  expect(blocking).toEqual([]);
});

For multi-step wizards, scan after each step. Dynamic content often injects unlabeled controls only after user input.

6. Wire GitHub Actions: how to add accessibility checks to CI

Make the local command the CI contract. A focused job keeps a11y failures readable in the Checks UI.

# .github/workflows/a11y.yml
name: Accessibility

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  axe:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Install Playwright browser
        run: npx playwright install --with-deps chromium

      - name: Run accessibility suite
        env:
          A11Y_USER: ${{ secrets.A11Y_USER }}
          A11Y_PASSWORD: ${{ secrets.A11Y_PASSWORD }}
          BASE_URL: ${{ vars.BASE_URL }}
        run: npx playwright test tests/a11y --reporter=list,html

      - name: Upload Playwright report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: a11y-playwright-report
          path: playwright-report
          retention-days: 14

      - name: Upload axe JSON dumps
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: a11y-axe-json
          path: test-results
          if-no-files-found: ignore
          retention-days: 14

For broader CI design patterns, combine this with GitHub Actions for Playwright and add CI to a test framework. Keep permissions minimal: this job usually needs only contents: read plus secrets for the test user.

7. Produce actionable reports, not wall-of-text logs

Failing with JSON.stringify(violations) works for small suites. As volume grows, write structured artifacts:

import fs from "node:fs";
import path from "node:path";

function writeAxeReport(pageName: string, results: Awaited<ReturnType<AxeBuilder["analyze"]>>) {
  const dir = path.join("test-results", "axe");
  fs.mkdirSync(dir, { recursive: true });
  fs.writeFileSync(
    path.join(dir, `${pageName}.json`),
    JSON.stringify(results, null, 2),
    "utf8"
  );
}

In assertions, show rule id, impact, help URL, and a sample selector:

function formatViolations(violations: typeof results.violations) {
  return violations
    .map((v) => {
      const nodes = v.nodes
        .slice(0, 3)
        .map((n) => n.target.join(" "))
        .join("; ");
      return `${v.id} [${v.impact}] ${v.help} -> ${nodes} (${v.helpUrl})`;
    })
    .join("\n");
}

expect(blocking, formatViolations(blocking)).toEqual([]);

Triage becomes faster when engineers can click the axe help URL and jump to the selector. Pair with screenshots on failure via Playwright's standard screenshot: "only-on-failure" config.

8. Combine static lint, unit rules, and runtime axe

Layered defense reduces CI time and improves signal:

  1. eslint-plugin-jsx-a11y (or framework equivalent) on every PR for alt text, label associations, and clickable non-interactive elements in source.
  2. Component tests that render design-system primitives with axe on isolated components.
  3. Full-page Playwright axe for integrated layouts, routing, and third-party embeds.
npm install -D eslint-plugin-jsx-a11y

Illustrative ESLint flat config fragment:

// eslint.config.js (fragment)
import jsxA11y from "eslint-plugin-jsx-a11y";

export default [
  {
    plugins: { "jsx-a11y": jsxA11y },
    rules: {
      ...jsxA11y.flatConfigs.recommended.rules,
    },
  },
];

Static rules fail in seconds. Reserve browser time for states that only appear after hydration or API data.

9. Handle flakiness, third parties, and progressive enhancement

Accessibility scans can flake when:

  • Animations rearrange the tree mid-scan.
  • Cookie banners inject late nodes.
  • Chat widgets load after network idle.
  • Feature flags change DOM shape between runs.

Stabilize with:

  • Explicit waits for the main landmark or a known ready selector.
  • page.emulateMedia({ reducedMotion: "reduce" }) when motion causes reflows.
  • Excluding known third-party roots via .exclude(...).
  • Deterministic feature flags for the a11y suite.
await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto("/app");
await page.getByRole("main").waitFor();
const results = await new AxeBuilder({ page })
  .exclude("#intercom-frame")
  .analyze();

If a third party cannot be excluded and fails rules you do not own, document the risk, file a vendor ticket, and keep the exclusion time-boxed. Do not disable your whole suite for one widget.

10. Scale multi-page coverage without slowing every PR

Full site crawls on every pull request are expensive. Use a tiered model:

Tier Trigger Scope Budget
PR smoke Every PR 5-15 critical routes < 10 minutes
Nightly deep Schedule Full route list + states 30-90 minutes
Release gate Tag / main PR set + high-risk flows Strict failures

Generate route lists from your app router or sitemap rather than hand-maintaining forever. Still review the list: dead routes and admin-only pages pollute signal.

const criticalRoutes = [
  "/",
  "/pricing",
  "/login",
  "/app/dashboard",
  "/app/settings/profile",
];

for (const route of criticalRoutes) {
  test(`axe: ${route}`, async ({ page }) => {
    await page.goto(route);
    const results = await new AxeBuilder({ page }).analyze();
    const blocking = results.violations.filter((v) =>
      ["critical", "serious"].includes(v.impact ?? "")
    );
    expect(blocking, route + "\n" + formatViolations(blocking)).toEqual([]);
  });
}

Track violation counts over time. A dashboard of open serious issues by rule id helps product owners prioritize.

11. Keyboard, focus, and contrast: beyond pure axe

axe-core is necessary but not sufficient. Add thin smoke tests for:

  • Tab order through primary flows.
  • Visible focus styles on interactive controls.
  • Escape closes dialogs and restores focus.
  • Skip links work.
test("dialog traps focus and restores on close", async ({ page }) => {
  await page.goto("/app");
  await page.getByRole("button", { name: "Open filters" }).click();
  const dialog = page.getByRole("dialog");
  await expect(dialog).toBeVisible();

  await page.keyboard.press("Tab");
  // Assert focus remains inside dialog for several tabs
  for (let i = 0; i < 5; i++) {
    await page.keyboard.press("Tab");
    const inside = await page.evaluate(() => {
      const root = document.querySelector('[role="dialog"]');
      return !!root && root.contains(document.activeElement);
    });
    expect(inside).toBe(true);
  }

  await page.keyboard.press("Escape");
  await expect(dialog).toBeHidden();
});

For visual contrast, prefer axe color-contrast rules first. Manual design review still owns brand-level contrast decisions for large illustration-heavy pages.

When comparing tools or expanding the program, the accessibility testing checklist remains a good companion for manual coverage your CI will never fully replace. For pipeline ownership topics adjacent to this guide, see flaky test quarantine in CI.

12. Governance, ownership, and rollout plan

Rollout fails when one enthusiast enables a red pipeline for the whole monorepo on day one. Prefer:

  1. Week 1: non-blocking job that uploads reports on main.
  2. Week 2: fail PR only for new serious violations on touched routes (or fail all serious on critical routes).
  3. Week 3+: expand routes, add lint, add nightly deep scan.
  4. Ongoing: exception review, design-system ownership of shared components, training for frontend engineers.

Assign CODEOWNERS for tests/a11y/** and design-system packages. Accessibility is a product quality property, not a private QA hobby.

Define SLAs: critical production issues fixed within one release cycle; serious within two; moderate tracked in backlog with quarterly cleanup. Measure escape defects found by users or audits that CI missed, then improve rules or coverage.

Interview Questions and Answers

Q: How would you add accessibility checks to CI without blocking the team on day one?

Start with a non-blocking job that runs axe on a small critical route set and uploads JSON/HTML artifacts. Socialize the report, fix systemic design-system issues, then flip the job to fail on critical and serious impacts once noise is under control.

Q: Why not rely only on Lighthouse in CI?

Lighthouse accessibility is useful as a broad score but is coarser and slower for pull-request gates. axe-core gives rule-level, WCAG-tagged violations with node targets that engineers can fix. Many teams keep Lighthouse nightly and axe on every PR.

Q: How do you handle third-party widgets that fail axe?

Exclude their DOM roots with documented owners and expiry, file vendor issues, and keep product-owned UI under full rules. Never disable the entire suite for a chat bubble.

Q: What belongs in automated CI versus manual accessibility testing?

Automate labels, roles, contrast (as rules allow), document structure, and many ARIA misuse patterns. Manual testing owns cognitive load, real assistive technology journeys, content clarity, and complex multi-step usability.

Q: How do you prevent exception rot from disableRules?

Require ticket links, owners, and expiry dates in code review. Fail CI if an exception is past its expiry. Review the exception list in a monthly quality meeting.

Q: How should authenticated pages be scanned?

Reuse the same deterministic auth path as e2e tests, store credentials in CI secrets, seed stable test users, and wait for application-ready markers before calling axe.

Q: What metrics show the program is working?

Trend of serious violations open, time-to-fix for a11y CI failures, percentage of critical routes covered, and count of production a11y defects that escaped the gate.

Common Mistakes

  • Turning on every axe rule on the whole site on day one, then disabling the job when noise spikes.
  • Scanning only the logged-out marketing site while the product lives behind auth.
  • Publishing reports only on success so failures leave no artifact.
  • Using production user data or real customer content in a11y fixtures.
  • Globally disabling color-contrast without design ownership or follow-up.
  • Treating a green axe run as "fully accessible" without keyboard or AT sampling.
  • Letting third-party iframes dominate failure lists with no exclusion policy.
  • Hard-coding sleeps instead of waiting for landmarks or network-stable app ready states.

Correct these with a written severity policy, tiered coverage, always-upload artifacts, and CODEOWNERS for a11y tests and shared components.

Conclusion

Learning how to add accessibility checks to CI is less about a single plugin and more about a durable quality gate: deterministic routes, axe-core in a real browser, severity-based failure, structured evidence, and layered static plus runtime checks. Start with critical and serious impacts on a short route list, publish artifacts on every outcome, then expand coverage and keyboard smokes as trust grows.

Your next step is concrete: add one Playwright axe test for your highest-traffic authenticated view, wire the GitHub Actions job above on a feature branch, and require the report artifact in code review before you make the check required on main.

13. Sample end-to-end fixture: reusable a11y helper

Centralize policy so every test does not reinvent severity filtering. A small helper keeps CI behavior consistent across packages in a monorepo.

// tests/a11y/axeHelper.ts
import AxeBuilder from "@axe-core/playwright";
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import fs from "node:fs";
import path from "node:path";

const BLOCKING_IMPACTS = new Set(["critical", "serious"]);

export async function expectNoBlockingAxeViolations(
  page: Page,
  options: {
    name: string;
    include?: string | string[];
    exclude?: string | string[];
    disableRules?: string[];
  }
) {
  let builder = new AxeBuilder({ page }).withTags([
    "wcag2a",
    "wcag2aa",
    "wcag21aa",
    "wcag22aa",
  ]);

  if (options.include) builder = builder.include(options.include);
  if (options.exclude) builder = builder.exclude(options.exclude);
  if (options.disableRules) builder = builder.disableRules(options.disableRules);

  const results = await builder.analyze();
  const outDir = path.join("test-results", "axe");
  fs.mkdirSync(outDir, { recursive: true });
  fs.writeFileSync(
    path.join(outDir, `${options.name}.json`),
    JSON.stringify(results, null, 2)
  );

  const blocking = results.violations.filter((v) =>
    BLOCKING_IMPACTS.has(v.impact ?? "")
  );

  const summary = blocking
    .map((v) => `${v.id} [${v.impact}] ${v.help}`)
    .join("\n");

  expect(blocking, summary).toEqual([]);
}

Use the helper from feature specs:

test("billing page a11y", async ({ page }) => {
  await page.goto("/app/billing");
  await page.getByRole("heading", { name: "Billing" }).waitFor();
  await expectNoBlockingAxeViolations(page, {
    name: "billing",
    exclude: "#stripe-elements-iframe-wrapper",
  });
});

This pattern documents the CI contract in code: tags, impacts, artifact location, and exclusion points. Reviewers can audit one file when policy changes.

14. Monorepo, multi-app, and design-system strategies

Large organizations rarely have one front-end. Accessibility CI should mirror ownership:

  • Design system package: component-level axe (or jest-axe / vitest + axe-core) on every primitive PR. This is the highest leverage gate because one unlabeled IconButton can ship to every product.
  • Product apps: route-level Playwright scans with shared helper and shared severity policy.
  • Marketing sites: simpler public URL lists, often pa11y-ci or Playwright without auth.

In CI, use path filters so a docs-only change does not run a 40-minute product a11y matrix, while a design-system change triggers both component tests and a smoke of consuming apps.

on:
  pull_request:
    paths:
      - "apps/web/**"
      - "packages/ui/**"
      - "tests/a11y/**"
      - "package-lock.json"

Publish a short architecture note in the repo: which job is required, which is informational, and who owns triage Slack/channel alerts when main goes red.

15. Security, privacy, and compliance notes for a11y CI

Accessibility artifacts can contain page HTML snippets, form values, and user-visible text. Treat them like other test evidence:

  • Use synthetic accounts, never real customer PII.
  • Redact tokens from traces and HAR files if you enable Playwright tracing for a11y debugging.
  • Limit artifact retention (for example 14 days for PR runs, longer for release audits if required).
  • Restrict who can download artifacts if reports include internal admin UI copy.

If you operate under procurement or legal accessibility commitments, keep a folder of CI configuration history and sample reports as process evidence. Automated gates support those programs; they do not replace them.

When your organization also invests in broader quality reporting, connect a11y outcomes to the same pipeline culture described in add reporting to a test framework so accessibility is not a siloed PDF once a year.

Interview Questions and Answers

How do you add accessibility checks to a CI pipeline?

I integrate axe-core in the existing browser test runner, scan critical authenticated and public routes, fail on critical and serious impacts, and always upload machine-readable plus HTML reports. I document exclusions and keep static jsx-a11y lint as a fast first layer.

How do you decide which axe violations fail the build?

I map impact levels to policy: critical and serious block merge, moderate may block only on main or open issues, minor is trend-tracked. The policy is written, not ad hoc per PR.

What are limitations of automated accessibility testing?

Automation misses many usability and cognitive issues, imperfectly judges some contrast cases, and cannot fully replace screen reader journeys. It is a regression net, not a complete compliance certificate.

How do you keep accessibility tests maintainable?

Centralize AxeBuilder defaults, share route lists, require owners for disableRules, tier PR versus nightly scope, and treat design-system components as the highest ROI scan targets.

How would you introduce a11y CI to a legacy app with hundreds of violations?

I baseline current violations, fail only on new regressions or on a critical path allowlist, burn down systemic design-system issues, then tighten the gate. Big-bang red builds get disabled by teams.

How do keyboard tests complement axe in CI?

Keyboard tests validate focus order, focus traps, escape behavior, and skip links that rule engines may not fully exercise as user journeys. A few smoke flows catch high-severity interaction bugs.

What evidence should an a11y CI job publish?

JSON violation dumps with rule ids and targets, a human HTML report, and failure screenshots. Artifacts must upload even when the test step fails.

Frequently Asked Questions

What is the fastest way to add accessibility checks to CI?

Install @axe-core/playwright, write one test for a critical route that fails on critical and serious impacts, and run it in GitHub Actions with artifact upload on always. Expand routes after the first green and red paths are understood.

Should accessibility failures block pull requests?

Yes for critical and serious violations once noise is controlled. Start non-blocking if the backlog is large, fix design-system root causes, then make the check required.

Is axe enough for WCAG compliance claims?

No. axe automates many checks but not all WCAG criteria. Combine CI rules with manual assistive technology testing and process evidence for formal compliance claims.

How do I scan pages behind login?

Reuse deterministic e2e auth helpers, store credentials in CI secrets, wait for an application-ready selector, then call AxeBuilder.analyze() on the authenticated DOM.

Which browsers should accessibility CI use?

Chromium is enough for most axe gates. Add WebKit or Firefox in nightly jobs if you rely on browser-specific accessibility tree differences.

How do I reduce a11y CI runtime?

Limit PR jobs to critical routes, parallelize by shard, reuse auth storage state, and push full crawls to nightly schedules.

Can I use Pa11y instead of Playwright axe?

Yes for simple URL lists. Prefer Playwright axe when you need authenticated state, complex flows, or shared e2e fixtures.

Related Guides