Configuring axe-core Rules and Handling False Positives
Permalink to "Configuring axe-core Rules and Handling False Positives"axe-core is the engine behind most automated accessibility testing: browser extensions, Lighthouse, jest-axe, Playwright and Cypress integrations. Out of the box it runs a broad rule set and reports violations, “incomplete” items that need human review, and passes. Data-heavy interfaces stress it: large tables trigger performance-sensitive rules, virtualised grids produce partial structures that look wrong out of context, and some colour-contrast checks cannot resolve text over gradients or images. Teams under deadline pressure respond by disabling rules globally — and lose the protection those rules gave everywhere else.
This page configures axe-core deliberately for data UIs and sets out a narrow, auditable process for genuine false positives. It belongs to automated accessibility testing pipelines.
Spec reference
Permalink to "Spec reference"axe-core’s configuration surface (v4):
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa', 'best-practice'] }— select rules by tag.rules: { 'rule-id': { enabled: false } }— enable or disable individual rules (use sparingly, and never globally for a real rule).- Context:
{ include: ['#grid'], exclude: ['.third-party-widget'] }— scope a scan. - Results:
violations,incomplete(needs review — often contrast over complex backgrounds or ARIA that axe cannot fully verify),passes,inapplicable. axe.configure({ checks, rules })for custom rules and checks.
Automated rules find a subset of WCAG failures — commonly estimated at somewhere between a third and a half of issues by count. They are good at missing names, invalid ARIA, contrast of plain text, duplicate ids and missing table headers; they cannot judge announcement quality, focus order or keyboard behaviour.
When to suppress — and when to fix
Permalink to "When to suppress — and when to fix"Suppress only when you have confirmed that the rule is wrong for this specific element: for example, color-contrast flagged “incomplete” on text over a chart gradient that you have measured manually at 7:1, or aria-required-children on a virtualised grid fixture scanned mid-render.
Fix, rather than suppress, whenever the rule is right and the fix is inconvenient. The most common “false positives” reported by teams in data UIs are true positives: scrollable-region-focusable on table wrappers, empty-table-header on action columns, aria-allowed-attr for aria-selected on table rows. Each of those is a real issue.
The misapplication to name is rules: { 'color-contrast': { enabled: false } } in the shared configuration because three charts produced incomplete results. That removes contrast checking from every page in the product.
Annotated code example
Permalink to "Annotated code example"// axe.config.js — shared configuration for the whole product
export const AXE_OPTIONS = {
runOnly: {
type: 'tag',
values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa', 'best-practice'],
},
// No global rule disabling here. Suppressions live in the ledger below.
};
// a11y-suppressions.js — narrow, reasoned, expiring
export const SUPPRESSIONS = [
{
rule: 'color-contrast',
selector: '.revenue-chart .axis-label',
reason: 'Incomplete over gradient; measured 7.1:1 manually on 2026-09-02 (both themes).',
owner: 'charts-team',
expires: '2026-12-31',
},
];
// In a Playwright test
import AxeBuilder from '@axe-core/playwright';
import { AXE_OPTIONS } from './axe.config.js';
import { SUPPRESSIONS } from './a11y-suppressions.js';
test('invoice grid, sorted state', async ({ page }) => {
await page.goto('/invoices');
await page.getByRole('button', { name: 'Amount' }).click();
const results = await new AxeBuilder({ page })
.options(AXE_OPTIONS)
.include('#invoice-grid') // scope to the component under test
.analyze();
const today = new Date().toISOString().slice(0, 10);
const live = SUPPRESSIONS.filter((s) => s.expires >= today);
const isSuppressed = (v, node) => live.some((s) =>
s.rule === v.id && node.target.join(' ').includes(s.selector.split(' ').at(-1)));
const violations = results.violations
.map((v) => ({ ...v, nodes: v.nodes.filter((n) => !isSuppressed(v, n)) }))
.filter((v) => v.nodes.length);
// Incomplete items are reported, not failed — they need a human decision
test.info().annotations.push({ type: 'axe-incomplete', description: String(results.incomplete.length) });
expect(violations, formatViolations(violations)).toEqual([]);
});
The suppression ledger has an expiry and an owner for a reason: suppressions outlive the conditions that justified them. A chart redesign can make the measured contrast false, and an expired suppression forces someone to look again.
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"axe-core does not test keyboard or screen reader behaviour. The table maps common data-UI rules to what they actually protect.
| Rule | What it catches in data UIs | What it cannot catch |
|---|---|---|
scrollable-region-focusable |
Unfocusable scrolling table wrappers | Whether the wrapper has a good name |
aria-required-children / -parent |
Grids with rows missing, cells outside rows | Keyboard model of the grid |
th-has-data-cells, td-headers-attr |
Broken header wiring | Whether headers make sense |
button-name, link-name |
Unnamed icon buttons | “Delete” repeated on every row |
aria-allowed-attr |
aria-selected on table rows |
Selection announcements |
color-contrast |
Low-contrast text | Non-text contrast of chart marks |
Integration context
Permalink to "Integration context"This configuration feeds the pipeline in setting up axe-core in a GitHub Actions pipeline and the component tests in writing jest-axe tests for data grid components. Scanning one state is rarely enough for data UIs; axe scans of grid states with Playwright drives the grid through sorted, filtered, editing and empty states.
Everything axe cannot see — announcements, focus, keyboard — is covered by smoke tests and manual audits: see screen reader smoke testing and manual accessibility audits.
Gotchas
Permalink to "Gotchas"Scanning mid-render. Virtualised and async grids scanned before data arrives produce empty-structure violations. Wait for a settled state (a status message or a row count) before scanning.
Shadow DOM. axe-core scans open shadow roots; closed shadow roots are invisible to it. Components with closed roots need their own tests.
Iframes. Embedded third-party widgets in iframes are scanned only if you configure frame scanning; exclude them explicitly if they are out of scope, and record that decision.
Design system notes
Permalink to "Design system notes"Publish the shared AXE_OPTIONS and the suppression ledger format from the design system repository, so every product scans with the same tags and suppresses in the same auditable way. Component-level tests in the design system should run with zero suppressions.
Testing checklist
Permalink to "Testing checklist"FAQ
Permalink to "FAQ"How do I handle an axe-core false positive?
Confirm it manually first. If the rule is genuinely wrong for that element, suppress that rule for that selector only, with a written reason, an owner and an expiry date, and report the suppression in your pipeline summary. Never disable the rule globally.
Which axe-core tags should a WCAG 2.2 AA project use?
wcag2a, wcag2aa, wcag21a, wcag21aa and wcag22aa, optionally with best-practice. Choose tags explicitly so the rule set does not change unexpectedly between axe versions.
What should I do with axe's incomplete results?
Review them. They are items axe could not decide automatically, often contrast over images or gradients. Record the decision, and suppress narrowly only if the manual check passes.
How much of WCAG does axe-core test?
Only part of it — mostly structural and attribute-level issues. Keyboard behaviour, focus order and the quality of announcements need smoke tests and manual review.
Related
Permalink to "Related"- axe-core in GitHub Actions — the pipeline this configures
- axe scans of grid states — scanning each UI state
- jest-axe for grid components — unit-level scans