Migrating from pa11y to axe-core in CI
This guide is part of Pa11y CI Integration, and it walks an existing pa11y-ci pipeline through a controlled migration to @axe-core/cli or axe-playwright. Teams move for finer rule control, richer per-violation output, and direct access to the axe-core engine without the Pa11y wrapper. The migration is mechanical but easy to botch: rule IDs differ, the threshold model differs, and the conformance “standard” maps to “tags.” Done carelessly, the new gate either passes everything (silent regressions) or fails everything (a blocked team). The safe path runs both tools in parallel until the axe-core results are trusted.
Baseline controls for the migration:
- A field-by-field mapping from
.pa11ycito axe-core flags orAxeBuildercalls - A rule-ID translation for any issues you previously ignored
- A threshold translation from Pa11y’s per-URL count to a severity gate
- A parallel-run window where both gates report but only Pa11y blocks
Root Cause / Context
A direct swap fails because Pa11y and axe-core describe the same issues with different vocabularies. Pa11y’s default htmlcs runner emits codes like WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail, while axe-core emits rule IDs like color-contrast. Anything you previously listed under Pa11y’s ignore must be re-expressed as an axe-core rule disable or a CSS-selector exclusion, or those known issues will suddenly block the build. Note that if your Pa11y config already ran the axe runner, the underlying engine and rule IDs are identical to axe-core — only the wrapper changes.
The threshold model also differs. Pa11y’s threshold is a raw violation count per URL; axe-core has no built-in count threshold, so the equivalent gate is a small script that fails on violations at or above a chosen severity (serious, critical). Translating a “threshold: 5” into a severity policy is the judgment call of the migration.
Configuration
Here is the before-and-after. The Pa11y config:
{
"defaults": {
"standard": "WCAG2AA",
"runners": ["axe", "htmlcs"],
"threshold": 2,
"ignore": [
"WCAG2AA.Principle1.Guideline1_4.1_4_3.G18.Fail",
"region"
]
},
"urls": ["http://localhost:3000/", "http://localhost:3000/pricing"]
}
The axe-playwright equivalent, with the ignored items translated to axe-core rule IDs and a severity-based gate replacing the count threshold:
// scripts/axe-migrate.mjs — axe-playwright replacement for the pa11y config.
import { chromium } from 'playwright';
import { AxeBuilder } from '@axe-core/playwright';
const URLS = ['http://localhost:3000/', 'http://localhost:3000/pricing'];
const BLOCK_AT = new Set(['serious', 'critical']); // severity gate, not a count
const browser = await chromium.launch();
let blocking = 0;
for (const url of URLS) {
const page = await browser.newPage();
await page.goto(url);
await page.waitForLoadState('networkidle'); // stable DOM before evaluating
const { violations } = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag22aa']) // == Pa11y "standard": WCAG2AA
.disableRules(['color-contrast', 'region']) // == the two Pa11y "ignore" entries
.analyze();
const gating = violations.filter((v) => BLOCK_AT.has(v.impact));
blocking += gating.length;
console.log(`${url} -> ${gating.length} blocking, ${violations.length} total`);
await page.close();
}
await browser.close();
process.exit(blocking > 0 ? 1 : 0); // non-zero fails the gate
The WCAG2AA.Principle1...1_4_3 htmlcs code maps to the axe-core color-contrast rule; the Pa11y region ignore maps to the axe-core region rule directly. The raw threshold: 2 becomes “block on any serious or critical violation” — a clearer policy than an arbitrary count.
If you prefer the CLI over a script, @axe-core/cli covers the simple multi-URL case:
# @axe-core/cli equivalent for a single URL with the same tag scope.
npx axe http://localhost:3000/ \
--tags wcag2a,wcag2aa,wcag22aa \
--disable color-contrast,region \
--exit # non-zero exit on any violation
Validation
During the parallel-run window, confirm axe-core sees the same real issues Pa11y did before you let it block. Diff the violation counts per URL.
# Phase 1: both run; pa11y blocks, axe reports. Compare their findings.
npx pa11y-ci --config .pa11yci --json > out/pa11y.json || true
node scripts/axe-migrate.mjs > out/axe.log; echo "axe exit=$?"
# Expect axe's blocking count to match pa11y's over-threshold URLs.
# Example reconciled output:
# http://localhost:3000/ -> 0 blocking, 1 total (matches pa11y)
# http://localhost:3000/pricing -> 1 blocking, 3 total (matches pa11y)
# axe exit=1
Once the counts reconcile across several PRs, flip axe-core to blocking and drop the Pa11y job. Keep the Pa11y config in version control one release longer in case you need to revert.
Edge Cases & Conditional Guards
- htmlcs-only rules: A few htmlcs findings have no direct axe-core rule. Decide per issue whether axe-core covers it under a different rule or whether it moves to manual review — do not silently drop it.
- Per-URL ignores: Pa11y allowed
ignoreper URL via object entries; replicate that in axe-core with a per-URLdisableRulesor a CSSexcludeselector rather than a global disable. - Async/virtualized content: axe-core evaluates whatever is in the DOM at scan time. Wait for
networkidleor a stable landmark so virtualized rows are present, matching Pa11y’swait.
Pipeline Impact
The migration changes the artifact shape: Pa11y’s per-runner JSON becomes axe-core’s per-rule violation objects, which are richer for PR annotation (each carries the rule ID, impact, and affected nodes). During the parallel window, the Pa11y job stays as the required check and the axe-core job runs with continue-on-error: true; at cutover you swap which job is required. Wire the new required check through Pull Request Gating & Branch Policies, and if you need a phased severity ramp, layer on Progressive Threshold Management.
Common Pitfalls
- Forgetting to translate
ignore: Un-mapped ignores become new blocking violations on day one. Map every entry to an axe-core rule first. - Treating
thresholdas a literal count: axe-core has no count threshold. Convert it to a severity policy. - Hard cutover with no overlap: Swapping in one PR hides discrepancies. Run both, reconcile, then flip.
- Dropping htmlcs-only findings silently: Some checks do not map one-to-one. Route them to manual review, do not lose them.
- Mismatched tags:
standard: WCAG2AAis not justwcag2aa; includewcag2aandwcag22aafor equivalent coverage.
FAQ
Will axe-core find the same violations my pa11y-ci setup did? Largely yes if Pa11y already used the axe runner — the engine is identical. Differences come from the htmlcs runner’s distinct rule set and from your ignore list. Run both in parallel and reconcile counts per URL before trusting axe-core as the sole gate.
How do I translate Pa11y’s numeric threshold to axe-core?
axe-core has no built-in count threshold, so replace it with a severity gate: fail the build on any violation whose impact is serious or critical, and treat minor/moderate as non-blocking or tracked. This is usually a clearer policy than an arbitrary per-URL count anyway.
Should I use @axe-core/cli or axe-playwright?
Use @axe-core/cli for simple multi-URL sweeps where the default options suffice. Choose axe-playwright when you need session state, per-URL rule disables, explicit waits for dynamic content, or custom severity logic — it gives the programmatic control the CLI cannot.
Related
- Pa11y CI Integration — the parent guide on the setup you are migrating from.
- axe-core Configuration & Setup — configuring the engine you are migrating to.
- Pull Request Gating & Branch Policies — swapping the required check at cutover.