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.

From scan result to decision Flow of handling axe results: violations fail the build, incomplete items are reviewed, confirmed false positives are suppressed narrowly with a reason and expiry, and suppressions are reported. From scan result to decisionScantags + scopedcontextViolationsfail the buildIncompletehuman reviewFalse positive?suppressnarrowly, withreasonReportsuppressions inthe summary
Every path ends in a recorded decision — nothing is silently ignored.

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
Common data-UI findings: false positive or real? Matrix of frequently disputed axe findings in data interfaces, whether each is usually a real issue, and the right response. Common data-UI findings: false positive or real?FindingUsuallyResponsescrollable-region-focusableon table wrapperRealAdd tabindex, role, nameempty-table-header onaction columnRealAdd a visually hidden headeraria-required-children onvirtual gridTimingScan after render settlescolor-contrast incompleteon chartsNeeds reviewMeasure; suppress narrowly if passingregion rule on dashboardcardsBest practiceDecide per design; document
Most "false positives" in data UIs turn out to be real — check before suppressing.

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.

Global rule disabling versus a suppression ledger Comparison of disabling an axe rule globally in configuration against suppressing a specific finding in a ledger with selector, reason, owner and expiry. Global rule disabling versus a suppression ledger✗ Disable globallyEvery page loses the checkNo reason recordedNever revisited✓ Suppression ledgerOne rule, one selectorWritten reason and measurementOwner and expiry dateListed in every pipeline summary
The ledger costs a few lines per entry and keeps every other page protected.

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.

Permalink to "Related"

← Back to Automated Accessibility Testing Pipelines