CI/CD Accessibility & Performance Best Practices
Integrating accessibility (a11y) and performance checks into your CI/CD pipeline ensures that regressions are caught before they reach a single user. This guide emphasizes a “Zero-Debt” strategy: achieving $100% scores on all pages, across all devices and user preferences.
The Strategy: Local-First & AI-Aligned
Automation is the baseline, not the ceiling. Our strategy uses:
- Lighthouse CI for high-level “quality gates” ($100% scores).
- Playwright + axe-core for dynamic elements, mobile emulation, and theme testing (Light/Dark).
- Local Audits as the default developer workflow to reduce CI noise.
- Structured Data (JSON) to ensure findings are “AI-ready” for automated remediation.
1. Local Development (The Baseline)
Run audits locally before pushing code. This is the fastest feedback loop and keeps the repository history clean.
Setup
npm install -g @lhci/cli
npm install -D @playwright/test @axe-core/playwright
Scripting
Add these to your package.json to normalize testing:
"scripts": {
"test:a11y": "lhci autorun && npx playwright test",
"test:a11y:local": "lhci collect --url=http://localhost:3000 && lhci assert"
}
2. Lighthouse CI (The $100% Gate)
We enforce a strict $100% score for both Accessibility and Performance. If a page drops to $99%, the build fails.
Configuration (.lighthouserc.js):
module.exports = {
ci: {
collect: {
staticDistDir: './_site',
numberOfRuns: 1,
settings: {
emulatedFormFactor: 'mobile',
},
},
assert: {
assertions: {
'categories:accessibility': ['error', { minScore: 1 }],
'categories:performance': ['error', { minScore: 1 }],
},
},
},
};
3. Playwright + axe-core (Dynamic & Preferences)
For pages with dynamic elements (menus, modals) or those requiring specific user preferences, Playwright is the superior tool.
// tests/a11y.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
const themes = ['light', 'dark'];
for (const theme of themes) {
test(`A11y: Desktop & Mobile in ${theme} mode`, async ({ page, isMobile }) => {
await page.emulateMedia({ colorScheme: theme as 'light' | 'dark' });
await page.goto('/');
// Interact with dynamic elements
const menuBtn = page.locator('#main-menu-toggle');
if (isMobile && await menuBtn.isVisible()) {
await menuBtn.click(); // Test the menu in its open state
}
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa'])
.analyze();
expect(results.violations).toEqual([]);
});
}
4. GitHub Actions
A. Monthly Accessibility Scanner with Alert-Fatigue Guard
This workflow uses the github/accessibility-scanner. It runs on the first day of every month and on demand, but only when there are no existing open accessibility issues. This prevents alert fatigue by pausing scans while known issues are still being resolved.
Workflow (.github/workflows/accessibility-scan.yml):
name: Accessibility Scan (Scheduled)
on:
schedule:
- cron: "0 0 1 * *" # First day of every month at 00:00 UTC
workflow_dispatch:
permissions:
contents: write
issues: write
pull-requests: write
jobs:
accessibility-scanner:
name: GitHub Accessibility Scanner
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Check for existing open accessibility issues
id: check_issues
env:
GH_TOKEN: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }}
run: |
COUNT=$(gh issue list --label "accessibility" --state open --json number --jq '. | length')
echo "count=$COUNT" >> $GITHUB_OUTPUT
- name: Run GitHub Accessibility Scanner
if: steps.check_issues.outputs.count == '0'
uses: github/accessibility-scanner@v3
with:
urls: ${{ vars.ACCESSIBILITY_SCAN_URL || format('https://{0}.github.io/{1}/', github.repository_owner, github.event.repository.name) }}
repository: ${{ github.repository }}
token: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }}
cache_key: accessibility-scan-results
Notes:
- The
GH_TOKENsecret is optional; the workflow falls back to the automaticGITHUB_TOKEN.- Set
ACCESSIBILITY_SCAN_URLas a repository variable to override the default GitHub Pages URL. Multiple URLs can be provided as a newline-separated list.- Running less frequently (monthly rather than weekly) means fewer unnecessary API calls and quieter notifications when the site is in good shape.
B. Lighthouse Accessibility Audit on Every PR
Catch regressions before they merge. This workflow builds the Jekyll site and runs a Lighthouse audit on every pull request and push to main.
Workflow (.github/workflows/lighthouse.yml):
name: Lighthouse CI
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
jobs:
lighthouse:
name: Lighthouse accessibility audit
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: "3.2"
bundler-cache: true
- name: Build Jekyll site
run: bundle exec jekyll build
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install Lighthouse CI
run: npm install -g @lhci/cli
- name: Run Lighthouse CI
run: lhci autorun
Configuration (.lighthouserc.json):
{
"ci": {
"collect": {
"staticDistDir": "./_site",
"numberOfRuns": 1
},
"assert": {
"assertions": {
"categories:accessibility": ["warn", { "minScore": 0.9 }]
}
},
"upload": {
"target": "filesystem",
"outputDir": ".lighthouseci"
}
}
}
Tip: Start with
"warn"while resolving existing issues, then tighten to"error"with"minScore": 1once you achieve a clean baseline (see Section 2 above).
C. Full Deep Crawl (AI-Ready)
A manual trigger to generate a full JSON report of the site’s state for AI analysis.
name: Deep Site Audit
on: workflow_dispatch
jobs:
crawl:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Playwright & Export JSON
run: npx playwright test --reporter=json > audit-report.json
- uses: actions/upload-artifact@v4
with:
name: a11y-json-report
path: audit-report.json
5. Accessibility Tree Testing
Automated WCAG rule checks (axe-core, Lighthouse) verify that markup follows accessibility rules, but they cannot tell you what a screen reader actually announces to the user. Accessibility tree testing queries the browser’s internal representation of the page — the same structure assistive technologies consume — so you can assert on the exact names, roles, and properties that users experience.
When to use: Add accessibility tree tests whenever you need to verify how content is announced — especially for complex components such as SVG diagrams, custom widgets, dynamic live regions, and navigation landmarks.
5.1 Playwright Aria Snapshots
Playwright exposes the accessibility tree as a YAML snapshot via toMatchAriaSnapshot() (added in Playwright v1.46). This is distinct from axe-core rule checks: instead of asking “does this pass WCAG rule X?”, you ask “is this exactly what a screen reader user would hear?”
Install:
npm install -D @playwright/test
Snapshot test example:
// tests/a11y-tree.spec.ts
import { test, expect } from '@playwright/test';
test('main navigation is correctly announced', async ({ page }) => {
await page.goto('/');
await expect(page.locator('nav[aria-label="Main navigation"]'))
.toMatchAriaSnapshot(`
- navigation "Main navigation":
- list:
- listitem:
- link "Home"
- listitem:
- link "About"
- listitem:
- link "Contact"
`);
});
test('SVG diagram is exposed as a labelled image', async ({ page }) => {
await page.goto('/diagrams');
// Decorative SVGs and unlabelled images will FAIL this test,
// surfacing real barriers for screen reader users.
await expect(page.locator('svg[role="img"]').first())
.toMatchAriaSnapshot(`
- img "User Authentication Flowchart":
`);
});
Generating snapshots from the command line:
Run the test with --update-snapshots once to generate the baseline:
npx playwright test tests/a11y-tree.spec.ts --update-snapshots
After intentional accessibility tree changes, regenerate the snapshot and review the diff the same way you review visual regression diffs.
Semantic queries with getByRole:
Querying by ARIA role and accessible name mirrors how assistive technology users locate elements. Use these queries in your existing Playwright tests to verify that accessible names are present and correct:
test('form controls have meaningful accessible names', async ({ page }) => {
await page.goto('/contact');
await expect(page.getByRole('textbox', { name: 'Email address' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Send message' })).toBeEnabled();
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Contact Us');
});
Note:
getByRole()queries fail when accessible names are missing or wrong, providing earlier feedback than a manual screen reader audit.
For more detail on Playwright’s accessibility tree API, see the Playwright aria snapshots documentation.
5.2 Guidepup — Virtual Screen Reader Testing
Guidepup provides reliable automation for screen reader workflows. Its virtual screen reader lets you unit-test the exact text that would be read aloud, without needing a real screen reader installed.
Install:
npm install -D @guidepup/virtual-screen-reader @guidepup/jest
Unit test example (Jest):
// tests/sr.test.ts
import { virtual } from '@guidepup/virtual-screen-reader';
describe('Screen reader announcement', () => {
it('announces the dialog title and action buttons', async () => {
document.body.innerHTML = `
<dialog open aria-labelledby="dlg-title">
<h2 id="dlg-title">Confirm deletion</h2>
<p>This action cannot be undone.</p>
<button>Delete</button>
<button>Cancel</button>
</dialog>
`;
await virtual.start({ container: document.body });
const spoken = await virtual.spokenPhraseLog();
expect(spoken).toContain('Confirm deletion');
expect(spoken).toContain('Delete, button');
expect(spoken).toContain('Cancel, button');
await virtual.stop();
});
});
GitHub Actions setup:
- name: Set up Guidepup
uses: guidepup/setup-action@v2
- name: Run virtual screen reader tests
run: npx jest tests/sr.test.ts
Note: The Guidepup Setup action also enables automation of real VoiceOver (macOS) and NVDA (Windows) when needed.
5.3 Comparing Testing Approaches
Use these approaches together to achieve full coverage. No single tool catches everything.
| Approach | Finds WCAG rule violations | Finds announcement quality issues | Works for SVG / canvas | CI-friendly |
|---|---|---|---|---|
| axe-core (Section 3) | ✅ | ❌ | Limited | ✅ |
| Lighthouse (Section 2) | ✅ | ❌ | ❌ | ✅ |
| Playwright aria snapshots | Partial | ✅ | ✅ | ✅ |
| Guidepup virtual screen reader | ❌ | ✅ | ✅ | ✅ |
| Manual screen reader testing | Partial | ✅ | ✅ | ❌ |
5.4 Future Standard: WebDriver BiDi
Playwright’s aria snapshot feature currently relies on the Chromium accessibility API. The W3C WebDriver BiDi specification is working to standardise accessibility tree access across all browsers, which will make these tests cross-browser in the future.
Governance & SLAs
- Critical Failures: Any page with a Lighthouse Accessibility score under $100% blocks the build.
- Performance Budget: Any page with a Lighthouse Performance score under $100% blocks the build.
- Triage: Failures from scheduled scans must be converted into GitHub Issues. If an issue remains open, subsequent scheduled scans are paused to prevent alert fatigue.
Alternative Tools & Resources
- AccessLint: A GitHub App that provides inline PR comments. A great backup for catching issues during code review without a full CI run.
- Open-Scans: Excellent for running external scans using multiple engines against a live URL.
- Lighthouse CI Documentation: Comprehensive guide for setting up an LHCI server for historical tracking.
- CivicActions: Scaling Automation: A deep dive into the philosophy of enterprise-scale a11y.
- OpenSource.com: Automated A11y: Practical examples of integrating these tools into open-source workflows.