axe-core Advanced Configuration: Custom Rules, Rule Overrides, and Selective Auditing
Most teams use axe-core with default settings: run it against a page, count violations, fail the build. That works until your design system produces dozens of false positives, or your app has accessibility requirements that no default rule covers. This guide covers the axe-core internals you need to write custom rules, suppress false positives cleanly, and run precise audits on complex component trees.
axe-core Architecture
Understanding axe-core's internal model lets you configure it correctly.
Rules → each rule tests one specific accessibility requirement (e.g., color-contrast, aria-required-attr) Checks → each rule is composed of one or more checks. Checks do the actual DOM evaluation. Context → what to scan: element selectors, include/exclude lists, iframes Options → which rules to run, rule overrides, reporter format
A rule has:
id: unique nameselector: CSS selector pre-filter (optimization)tags: WCAG criteria this rule coversall,any,none: arrays of check IDs (check semantics differ)enabled: boolean, can be overridden per-run
Context Configuration
Include/exclude selectors
import AxeBuilder from '@axe-core/playwright';
// Only audit a specific component
const results = await new AxeBuilder({ page })
.include('#main-content')
.analyze();
// Audit everything except third-party widgets
const results = await new AxeBuilder({ page })
.exclude('#intercom-frame')
.exclude('.third-party-chat')
.exclude('[data-noaudit]')
.analyze();
// Audit multiple sections
const results = await new AxeBuilder({ page })
.include('#header')
.include('#main')
.include('#footer')
.analyze();iframe handling
By default, axe-core audits iframes on the same origin. Control this explicitly:
// axe-core direct (not playwright wrapper)
const results = await axe.run(
{
include: [['#main']],
// Explicitly include a same-origin iframe
include: [['#main'], ['#my-iframe', '#form-inside-iframe']]
},
{
iframes: true // scan same-origin iframes
}
);Dynamic context from test data
// Audit only rendered items in a list
async function auditDataTable(page, tableSelector) {
const rowCount = await page.locator(`${tableSelector} tbody tr`).count();
if (rowCount === 0) {
console.log('Empty table — skipping data row audit');
return { violations: [] };
}
return new AxeBuilder({ page })
.include(tableSelector)
.withRules(['td-headers-attr', 'th-has-data-cells', 'scope-attr-valid'])
.analyze();
}Rule Configuration
Disabling rules
Disable a rule globally when it produces false positives in your environment:
// Playwright
const results = await new AxeBuilder({ page })
.disableRules(['color-contrast']) // if your CI is headless with different rendering
.analyze();
// axe-core directly
const results = await axe.run(document, {
rules: {
'color-contrast': { enabled: false },
'duplicate-id': { enabled: false } // if server-rendered HTML with known dupe IDs
}
});Rule options / overrides
Some rules accept options that change their thresholds:
// Relax color contrast for large text components
const results = await axe.run(document, {
rules: {
'color-contrast': {
enabled: true,
options: {
// Override contrast ratio thresholds
noScroll: true, // don't scroll before checking
cssPropertyFilter: [] // include CSS-hidden elements
}
}
}
});Running only specific rules
// Run only the rules that matter for a form component
const formResults = await new AxeBuilder({ page })
.include('#contact-form')
.withRules([
'label',
'label-content-name-mismatch',
'aria-required-attr',
'aria-required-children',
'aria-valid-attr-value',
'autocomplete-valid',
'form-field-multiple-labels'
])
.analyze();Writing Custom Rules
Custom rules cover requirements that default axe doesn't test. Common use cases: design system tokens, brand-specific requirements, internal ARIA patterns.
Rule anatomy
axe.configure({
rules: [{
id: 'my-custom-rule',
selector: 'button', // CSS pre-filter
tags: ['custom', 'brand'], // arbitrary tags for filtering
metadata: {
url: 'https://your-docs/accessibility/buttons',
description: 'Buttons must have a data-analytics attribute',
help: 'All buttons need data-analytics for event tracking',
helpUrl: 'https://your-docs/accessibility/buttons'
},
all: [],
any: ['button-has-analytics'],
none: []
}],
checks: [{
id: 'button-has-analytics',
evaluate: function(node, options, virtualNode, context) {
return node.hasAttribute('data-analytics');
},
metadata: {
impact: 'moderate',
messages: {
pass: 'Button has data-analytics attribute',
fail: {
missingAttribute: 'Button is missing data-analytics attribute'
}
}
}
}]
});Check return values
The evaluate function in a check returns:
true→ check passesfalse→ check fails with the default fail messagethis.data({ messageKey: 'myKey', ... })then returnfalse→ check fails with a specific message key
checks: [{
id: 'heading-level-sequence',
evaluate: function(node, options) {
const level = parseInt(node.tagName.replace('H', ''), 10);
// Get previous heading
const headings = Array.from(document.querySelectorAll('h1,h2,h3,h4,h5,h6'));
const idx = headings.indexOf(node);
if (idx === 0) return true; // first heading, always valid
const prevLevel = parseInt(headings[idx - 1].tagName.replace('H', ''), 10);
if (level > prevLevel + 1) {
this.data({
messageKey: 'skippedLevel',
prevLevel,
currentLevel: level
});
return false;
}
return true;
},
metadata: {
impact: 'moderate',
messages: {
pass: 'Heading level follows sequence',
fail: {
skippedLevel: 'Heading skips from h${data.prevLevel} to h${data.currentLevel}'
}
}
}
}]Using virtualNode for performance
The virtualNode parameter gives you a lightweight representation of the node and its ancestors/children without repeated DOM queries:
evaluate: function(node, options, virtualNode) {
// Get children without DOM query
const children = virtualNode.children;
// Walk ancestors
let ancestor = virtualNode.parent;
while (ancestor) {
if (ancestor.actualNode.getAttribute('role') === 'listbox') {
return true; // found containing listbox
}
ancestor = ancestor.parent;
}
this.data({ messageKey: 'noListboxAncestor' });
return false;
}Custom checks with options
Pass configuration to checks at rule evaluation time:
axe.configure({
checks: [{
id: 'min-touch-target',
evaluate: function(node, options) {
const rect = node.getBoundingClientRect();
const minSize = options.minSize || 44; // default 44px (iOS HIG)
if (rect.width < minSize || rect.height < minSize) {
this.data({
messageKey: 'tooSmall',
width: Math.round(rect.width),
height: Math.round(rect.height),
required: minSize
});
return false;
}
return true;
},
metadata: {
impact: 'serious',
messages: {
pass: 'Touch target meets minimum size',
fail: {
tooSmall: 'Touch target is ${data.width}x${data.height}px, minimum is ${data.required}x${data.required}px'
}
}
}
}],
rules: [{
id: 'touch-target-size',
selector: 'a, button, [role="button"], [role="link"], input, select',
tags: ['mobile', 'touch'],
metadata: {
description: 'Interactive elements must meet minimum touch target size',
help: 'Ensure touch targets are at least 44x44px for mobile usability',
helpUrl: 'https://developer.apple.com/design/human-interface-guidelines/buttons'
},
all: [],
any: ['min-touch-target'],
none: []
}]
});
// Use with custom minSize
const results = await axe.run(document, {
rules: { 'touch-target-size': { enabled: true } },
checks: {
'min-touch-target': {
options: { minSize: 44 } // iOS HIG
}
}
});Registering Custom Rules in Different Integrations
jest-axe
import { axe, toHaveNoViolations, configureAxe } from 'jest-axe';
// Configure once in setup file
const customAxe = configureAxe({
rules: [{
id: 'data-analytics-on-buttons',
// ... rule definition
}]
});
expect.extend(toHaveNoViolations);
test('Button component', async () => {
const { container } = render(<Button onClick={fn}>Submit</Button>);
const results = await customAxe(container, {
runOnly: ['wcag2aa', 'custom']
});
expect(results).toHaveNoViolations();
});Playwright persistent configuration
// fixtures/axe.ts
import { test as base } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import { customRules, customChecks } from './axe-custom-rules';
type AxeFixture = {
makeAxeBuilder: () => AxeBuilder;
};
export const test = base.extend<AxeFixture>({
makeAxeBuilder: async ({ page }, use) => {
const makeAxeBuilder = () => new AxeBuilder({ page })
.configure({
rules: customRules,
checks: customChecks
})
.withTags(['wcag2aa', 'wcag22aa', 'custom']);
await use(makeAxeBuilder);
}
});
// In test file:
import { test } from './fixtures/axe';
test('homepage accessibility', async ({ page, makeAxeBuilder }) => {
await page.goto('/');
const results = await makeAxeBuilder().analyze();
expect(results.violations).toEqual([]);
});Managing False Positives
False positives fall into categories: rendering environment artifacts, known third-party violations, design exceptions with approved rationale.
Element-level suppression
<!-- In HTML: suppress specific checks on specific elements -->
<div class="legacy-widget" data-axe-disabled="color-contrast">
<!-- Third-party widget with non-negotiable contrast -->
</div>// In axe-core config:
const results = await axe.run(document, {
elementRef: true // includes element references in results
});
// Or use CSS attribute to suppress at selector level
axe.configure({
rules: [{
id: 'color-contrast',
// Exclude elements with data-axe-disabled="color-contrast"
selector: ':not([data-axe-disabled~="color-contrast"])'
}]
});Violation allowlist with documentation
Maintain an allowlist with justification:
// axe-allowlist.js
export const ALLOWLISTED_VIOLATIONS = [
{
ruleId: 'color-contrast',
selector: '.legacy-chart-tooltip',
reason: 'Third-party chart library (v2.3.1). Vendor issue filed: github.com/...',
expires: '2024-06-01',
approvedBy: 'a11y-team'
},
{
ruleId: 'duplicate-id',
selector: '#server-rendered-component',
reason: 'SSR limitation in legacy module. Tracked in JIRA-1234.',
expires: null // permanent exception
}
];
// Filter function
function filterAllowlisted(violations, page) {
return violations.filter(violation => {
return !ALLOWLISTED_VIOLATIONS.some(entry => {
if (entry.ruleId !== violation.id) return false;
if (entry.expires && new Date(entry.expires) < new Date()) {
console.warn(`EXPIRED allowlist entry: ${entry.ruleId} for ${entry.selector}`);
return false;
}
return violation.nodes.every(node => node.target[0]?.includes(entry.selector));
});
});
}Audit Result Processing
Severity-based reporting
const SEVERITY_ORDER = ['critical', 'serious', 'moderate', 'minor'];
function processResults(results, options = {}) {
const { failThreshold = 'serious', warnThreshold = 'moderate' } = options;
const failIdx = SEVERITY_ORDER.indexOf(failThreshold);
const warnIdx = SEVERITY_ORDER.indexOf(warnThreshold);
const failures = results.violations.filter(v =>
SEVERITY_ORDER.indexOf(v.impact) <= failIdx
);
const warnings = results.violations.filter(v => {
const idx = SEVERITY_ORDER.indexOf(v.impact);
return idx > failIdx && idx <= warnIdx;
});
return { failures, warnings, passes: results.passes };
}
const { failures, warnings } = processResults(auditResults, {
failThreshold: 'serious',
warnThreshold: 'moderate'
});
if (failures.length > 0) {
console.error(`${failures.length} violations fail the build`);
process.exit(1);
}
warnings.forEach(w => console.warn(`WARN: ${w.id} (${w.impact})`));SARIF output for GitHub code scanning
import { writeSarif } from 'axe-sarif-converter';
import { writeFileSync } from 'fs';
const sarif = writeSarif(results);
writeFileSync('axe-results.sarif', JSON.stringify(sarif));# In GitHub Actions:
- name: Upload SARIF
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: axe-results.sarif
category: accessibilityPerformance Optimization
axe-core scanning is synchronous and CPU-intensive. For large pages:
// Limit scan to visible viewport
const results = await axe.run({
include: [['body']],
}, {
runOnly: ['wcag2aa'],
// Only check elements in viewport
elementRef: false,
// Disable expensive rules for fast checks
rules: {
'color-contrast': { enabled: false } // most expensive rule
}
});
// Run expensive rules separately on demand
const contrastResults = await axe.run(document, {
rules: { 'color-contrast': { enabled: true } },
runOnly: ['color-contrast']
});Parallel page auditing
async function auditAllPages(urls) {
const browser = await chromium.launch();
// Audit pages in parallel (limit concurrency)
const CONCURRENCY = 4;
const results = [];
for (let i = 0; i < urls.length; i += CONCURRENCY) {
const batch = urls.slice(i, i + CONCURRENCY);
const batchResults = await Promise.all(
batch.map(async url => {
const page = await browser.newPage();
await page.goto(url);
await page.waitForLoadState('networkidle');
const audit = await new AxeBuilder({ page })
.withTags(['wcag2aa', 'wcag22aa'])
.analyze();
await page.close();
return { url, ...audit };
})
);
results.push(...batchResults);
}
await browser.close();
return results;
}Custom rules and precise context configuration are what separate axe-core as a quality gate from axe-core as a rubber stamp. Write rules for your design system's specific requirements, maintain an allowlist with expiry dates and justifications, and use tags to group rules by context (mobile, SPA navigation, forms). The default configuration gets you started — advanced configuration makes it maintainable.