axe-core
axe-core is the accessibility rules engine that most automated a11y tooling is built on. You load it into a page, call axe.run(), and get back four arrays: violations, passes, incomplete (things a machine cannot decide), and inapplicable. Each violation names the rule, an impact level from minor to critical, a link to Deque University explaining the fix, and CSS selectors for every failing element. The rules are tagged by standard, so you can ask for only WCAG 2.1 AA, or only Section 508, or add Deque's best-practice checks. It is a browser-side library with zero runtime dependencies, which is why Lighthouse, the axe browser extensions, and the Playwright, Cypress, and Jest integrations all wrap this same engine rather than writing their own.
The best automated accessibility engine available and the right foundation for a CI gate, as long as everyone understands it catches roughly half of WCAG issues and never replaces manual and assistive-technology testing. Unless you are building tooling, use one of the official framework wrappers rather than this package directly.
Use it if
- You want accessibility regressions caught in CI alongside your existing end-to-end tests, on the rendered DOM rather than on source files
- You need results tied to a named standard: runOnly with tags like wcag21aa or EN-301-549 gives you exactly the rule set your compliance target requires
- You are building a tool that reports accessibility problems and want an engine with stable rule IDs, help URLs, and impact levels rather than writing checks yourself
- You test components in isolation and want a per-component gate: jest-axe wraps this engine and fails a unit test on rendered markup
- You think passing it means the page is accessible: the README puts automated coverage at an average of 57% of WCAG issues, so a green run says nothing about focus order, meaningful alt text, or whether a custom widget is usable with a screen reader
- You are writing test code rather than tooling: install @axe-core/playwright, cypress-axe, or jest-axe instead, because they handle injection into every frame, result formatting, and driver quirks that you would otherwise reimplement
- Your tests run in jsdom: support there is explicitly limited and color-contrast, the single most common real-world violation, does not work at all, so a jsdom-only gate gives false confidence
- You need a source linter that flags problems before the page renders: axe-core needs a real DOM, so it cannot tell you about a missing alt attribute until the component has been mounted; the axe-linter editor extension is a separate product
- Your legal team is strict about copyleft: axe-core is MPL-2.0, which is file-level copyleft on modifications, and the AXE and AXE-CORE names are Deque trademarks governed by a separate policy, so forking and rebranding is not a free action
Setup reality
npm install --save-dev axe-core gives you a single UMD script with no dependencies, which is the easy part. The friction is everything around getting it into the page. Because it runs inside the browser, you have to inject axe.min.js into every frame you want tested, and axe.configure() does not propagate into iframes, so any rule tweaks must be applied in each frame separately. Cross-origin frames need an allowedOrigins configuration on both sides or results simply come back missing. In driver-based setups such as Selenium and Puppeteer, the project recommends the runPartial and finishRun pair rather than plain axe.run, which is exactly the sort of plumbing the official integrations exist to hide. The other real cost is upgrade behaviour: a new minor lands every three to five months and usually adds rules, so a build that was green on 4.12 can fail on 4.13 with no change to your code, and security fixes only go back 18 months of minor lines. Budget time for triage after every bump, and expect a first run on an existing app to produce a large violation list that needs to be turned into a baseline rather than a blocking gate on day one.
Patterns
Scan the whole documentrun-on-page
<script src="node_modules/axe-core/axe.min.js"></script>
<script>
axe.run().then(results => {
console.log(results.violations.length, 'violations');
console.log(results.incomplete.length, 'need review');
});
</script>With no callback axe.run returns a promise, but it does not polyfill one. It must be loaded inside every frame you want covered; a script tag in the top document alone will not reach an iframe.
Run only the rules for one conformance targetlimit-to-standard
const results = await axe.run({
runOnly: {
type: 'tag',
values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa']
}
});Tags do not nest: asking for wcag2aa alone does not include wcag2a, so list every level you care about. Leaving runOnly off also runs Deque's best-practice rules, which are opinions rather than WCAG requirements and will inflate your violation count.
Include and exclude parts of the pagescope-the-scan
const results = await axe.run({
include: ['main'],
exclude: ['.ad-banner', '[data-third-party]']
});Excluding third-party embeds is usually the difference between a usable report and noise you cannot fix. You can also pass a plain element, a NodeList, or a selector string as the first argument when you do not need both lists.
Turn violations into a useful CI failurefail-a-test-readably
const { violations } = await axe.run();
if (violations.length) {
const report = violations
.map(v => `${v.impact} ${v.id}: ${v.help}\n ${v.nodes.map(n => n.target.join(' ')).join('\n ')}\n ${v.helpUrl}`)
.join('\n\n');
throw new Error(`Accessibility violations:\n\n${report}`);
}Printing helpUrl in the failure is what makes the gate survive contact with a team: every rule links to a Deque University page explaining the fix. node.target is an array because each entry is one frame level deep.
Turn off a rule you have consciously accepteddisable-rules
const results = await axe.run({
rules: {
'color-contrast': { enabled: false },
'region': { enabled: false }
}
});Prefer disabling per run over axe.configure, which is global and does not cross iframe boundaries. Record why in a comment; a disabled rule with no reason gets copied forward forever.
Wire it into a Playwright testplaywright-integration
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('checkout page has no a11y violations', async ({ page }) => {
await page.goto('/checkout');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.exclude('#stripe-frame')
.analyze();
expect(results.violations).toEqual([]);
});AxeBuilder injects axe-core into every frame and uses runPartial internally, which is the pattern the project recommends for driver-based runs. Doing this by hand with page.evaluate misses iframes.
Assert on a single component in Jestcomponent-unit-test
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
test('Button is accessible', async () => {
const { container } = render(<Button>Save</Button>);
expect(await axe(container)).toHaveNoViolations();
});This runs in jsdom, where color-contrast silently does nothing because there is no layout or painting. Treat component-level checks as a cheap first pass and keep a browser-based run for the real coverage.
Cut analysis time on very large pagesspeed-up-large-pages
const results = await axe.run({
resultTypes: ['violations']
});The docs note that pages over about 50,000 elements can take more than 10 seconds, largely because axe computes a unique selector for every result. resultTypes limits the full detail to violations and returns a single sample for passes, incomplete, and inapplicable.
Target content inside frames or a shadow rootframes-and-shadow-dom
// only the form inside the payment iframe
await axe.run({ fromFrames: ['iframe#payment', 'form'] });
// skip comment bodies inside a shadow host
await axe.run({
exclude: { fromShadowDom: ['.commentsShadowHost', '.commentBody'] }
});Cross-origin frames need axe.configure({ allowedOrigins: [...] }) called inside each frame, otherwise results come back quietly incomplete. Do not use the <unsafe_all_origins> wildcard on anything but a local fixture.
Do something with the incomplete resultsreview-incomplete
const { incomplete } = await axe.run();
for (const item of incomplete) {
console.warn(`needs review: ${item.id} on ${item.nodes.length} node(s)`);
for (const node of item.nodes) console.warn(' ', node.html);
}Incomplete items are where axe could not decide, commonly contrast over a background image or an element it could not resolve. Most teams throw this array away, which is where a real chunk of the misses live.
Pin an explicit rule list so upgrades cannot break CIpin-rule-set
const RULES = ['button-name', 'image-alt', 'label', 'link-name', 'color-contrast'];
const results = await axe.run({
runOnly: { type: 'rule', values: RULES }
});Minor releases add new rules every few months, so a tag-based run can fail after a dependency bump with no code change. An explicit rule list trades coverage for a stable gate, which is a reasonable first step when adopting axe on an existing product.
Inspect what rules exist and what they coverlist-available-rules
const rules = axe.getRules(['wcag22aa']);
console.table(rules.map(r => ({ id: r.ruleId, help: r.description })));
// everything, with tags
console.log(axe.getRules().length);Useful when writing your own report or deciding on a baseline. Pass an array of tags to filter; with no argument you get every rule the engine knows, including experimental ones that are disabled by default.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| @axe-core/playwright | npm | You run Playwright and want the same engine with frame injection, tag filtering, and result handling already wired up. |
| jest-axe | npm | You want per-component accessibility assertions in unit tests and can live with jsdom's limits, notably no colour contrast checking. |
| pa11y | npm | You want a command-line crawler and CI reporter over a list of URLs rather than a library to embed in your own tests. |
| cypress-axe | npm | Your end-to-end suite is Cypress and you want a cy.checkA11y() command that fails the spec on violations. |