Overview

pa11y runs WCAG accessibility checks against a URL or a set of URLs. The default configuration fails on any error, which is too strict for codebases migrating incrementally and too loose for teams that want to catch regressions. A well-configured threshold strategy allows zero new violations while letting known legacy issues stay visible. The broader accessibility playbook lives in accessibility; running pa11y locally is covered in run-pa11y-locally.

Prerequisites

  • pa11y installed: npm install -D pa11y pa11y-ci.
  • A running local or staging server, or a set of public URLs to test.
  • An existing pa11y run with baseline results so you know what the current violation count is.
npx pa11y http://localhost:8080 --reporter json > baseline.json

Steps

1. Create a .pa11yci configuration file

pa11y-ci reads .pa11yci (JSON) at the project root. Start with sensible defaults.

{
  "defaults": {
    "timeout": 30000,
    "wait": 500,
    "standard": "WCAG2AA",
    "runners": ["axe", "htmlcs"],
    "ignore": [],
    "threshold": 0
  },
  "urls": [
    "http://localhost:8080",
    "http://localhost:8080/blog",
    "http://localhost:8080/contact"
  ]
}

threshold: 0 means zero errors are permitted. This is the target state. On a codebase with existing violations, you will raise this number temporarily (step 3) and lower it as issues are fixed.

2. Add per-rule overrides with ignore patterns

Use ignore to exclude specific WCAG rules that are accepted as known exceptions. Document the reason in a comment file, since JSON does not support inline comments.

{
  "defaults": {
    "standard": "WCAG2AA",
    "ignore": [
      "WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail",
      "color-contrast"
    ]
  }
}

Prefer ignoring specific rule codes over entire principles. Ignoring Principle1 silences too many rules and hides real regressions.

To ignore a violation only on a specific page, put the ignore key inside the URL entry rather than defaults:

{
  "defaults": { "standard": "WCAG2AA" },
  "urls": [
    {
      "url": "http://localhost:8080/legacy-form",
      "ignore": ["WCAG2AA.Principle2.Guideline2_4.2_4_1.H64.1"]
    }
  ]
}

This scopes the exception to one page and prevents it from silently spreading. See html-aria-patterns for the correct ARIA patterns that fix these rules permanently.

3. Set a realistic threshold during migration

If the baseline has 40 existing violations, set threshold: 40 today and schedule a sprint to reduce it to zero. The threshold acts as a ratchet: never raise it, only lower it.

{
  "defaults": {
    "threshold": 40
  }
}

Track the number in a docs/accessibility-debt.md file or a GitHub issue with a checkbox list of rules to fix. See html-semantic-elements for the most common fixes.

4. Wire pa11y-ci to GitHub Actions

Run pa11y-ci against the deployed preview URL on every pull request. Use pa11y-ci (not pa11y) to test multiple pages in one run.

# .github/workflows/a11y.yml
name: Accessibility
on: [push, pull_request]
 
jobs:
  pa11y:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: "22" }
      - run: npm ci
      - run: npm run build && npx serve -s public -p 8080 &
      - run: sleep 3 && npx pa11y-ci

For server-rendered sites, start the dev server instead of serving the static build. See github-actions for caching and reuse patterns.

5. Tighten the threshold over time

Each sprint, reduce threshold by the number of violations fixed. When threshold reaches zero and ignore is empty, the accessibility baseline is clean. Remove the threshold key entirely; pa11y-ci defaults to zero.

Use the JSON reporter to track progress over time:

npx pa11y-ci --reporter json 2>/dev/null | jq '.totals'
# { "errors": 12, "warnings": 4, "notices": 7 }

Verify it worked

# Zero errors on clean pages
npx pa11y-ci
# Expected: "Errors: 0"
 
# Confirm ignore patterns suppress only the intended rules
npx pa11y http://localhost:8080 --reporter json | jq '.[].code' | sort -u
# The ignored codes should not appear.
 
# Confirm CI fails when a new violation is introduced
# Temporarily add a <img> without alt, run pa11y-ci, expect non-zero exit.

Common errors

  • pa11y times out on JavaScript-heavy pages. Increase timeout to 60000 and add wait: 1000 to let the page fully render before scanning.
  • Violations appear in CI but not locally. The CI build uses a different base URL. Check that urls in .pa11yci match the server address in the workflow.
  • threshold is set but CI still passes with new violations. threshold is a maximum count, not a delta. If you add two violations and remove two, the count stays the same and CI passes. To catch regressions, track per-rule counts in a separate script.
  • ignore array has no effect. The rule code must match exactly, including the full dotted path. Copy the code from pa11y --reporter json output.
  • axe and htmlcs report contradictory results. Both runners implement WCAG differently. Prefer axe for programmatic correctness; use htmlcs for structural checks. Remove one runner if the noise is high.