Reducing False Positives in Automated Accessibility Scanners
Almost nothing a mature scanner reports is a false positive. What teams call a false positive is usually one of three other things: a result the engine explicitly declined to decide, a real barrier the team does not want to pay to fix this quarter, or a decorative element that was never marked as decorative. This guide is part of axe-core Configuration & Setup, and it covers how to tell those apart, why the incomplete bucket is not a pass, how contrast checks get defeated by real page composition, and what a suppression record has to contain before a reviewer is allowed to approve it.
Root Cause
An axe.run() result contains four arrays, and the trouble starts when a pipeline reads only one of them. violations holds nodes that failed a check. passes holds nodes that satisfied it. inapplicable holds rules that found nothing to test. incomplete holds nodes where a check returned undefined — meaning the rule matched the element, ran, and could not reach a verdict from the DOM alone. A colour-contrast check over text sitting on a background image is the canonical case: axe can read the computed color, cannot resolve a single background colour behind the glyphs, and refuses to guess in either direction. Pipelines that filter on results.violations.length === 0 treat every one of those as a pass, which is how a page with fourteen unresolved contrast pairs ships a green check.
The second confusion is between “the tool is wrong” and “we do not want to fix this”. A legacy data table with no header association genuinely fails WCAG 2.2 SC 1.3.1 (Info and Relationships); a screen-reader user genuinely gets a wall of unlabelled numbers. Deciding to defer that fix is a legitimate engineering trade-off, but it is a risk acceptance, not a scanner defect, and recording it as disableRules: ['td-headers-attr'] destroys the distinction permanently. Six months later nobody can tell whether that rule is off because the tool misfires or because a team ran out of sprint. The remedy is not fewer suppressions; it is suppressions that carry the reason they exist, scored by user impact using something like the model in scoring accessibility violations by user impact.
The third confusion is decorative content. A scanner that flags a 1×1 tracking pixel for a missing alt, or reports contrast against a purely ornamental flourish, is describing the markup accurately: the element is in the accessibility tree and it carries no accessible name. The correct fix is to remove it from the tree with aria-hidden="true" or an empty alt attribute, which fixes the actual experience — a screen-reader user no longer hears “image” for a spacer — rather than muting the rule and leaving the tree noisy. Reaching for an exclusion selector instead trades a two-character markup change for a permanent configuration entry that also hides real defects in the same subtree.
Contrast deserves its own explanation because it produces more claimed false positives than every other rule combined. axe walks up from the text node collecting background colours until it finds an opaque one. Three page patterns break that walk. A CSS gradient has no single colour, so the check cannot pick a value and returns undefined. A raster background image is opaque to the algorithm — axe cannot sample pixels — so it also yields incomplete. And an element whose background is rgba(15, 23, 42, 0.55) over an unknown parent forces a composite calculation against a colour the engine could not resolve, giving the same result. In all three cases the honest engineering answer is not to disable color-contrast; it is to give the text an opaque layer of its own — a solid scrim behind a hero headline, a token-driven card surface behind body copy — so the check can compute a ratio and enforce WCAG 2.2 SC 1.4.3 (Contrast Minimum) like anywhere else.
Configuration
Make the suppression list a data file with a schema, not a set of flags scattered through test code. Six fields are the minimum that survives a personnel change: the rule ID, the selector it applies to, the WCAG success criterion at stake, a justification written for someone who was not in the room, the owning team or individual, and a review date.
{
"$comment": "a11y/suppressions.json — reviewed in PR, enforced in CI",
"records": [
{
"ruleId": "color-contrast",
"selector": ".campaign-hero .eyebrow",
"wcag": "SC 1.4.3 Contrast (Minimum)",
"kind": "false-positive",
"justification": "Opaque scrim behind the text; measured 7.1:1 on the built page.",
"owner": "@acme/growth-web",
"reviewBy": "2026-10-01"
},
{
"ruleId": "td-headers-attr",
"selector": "#legacy-billing-table",
"wcag": "SC 1.3.1 Info and Relationships",
"kind": "accepted-risk",
"justification": "Real failure; the table is replaced by the new billing UI in Q4.",
"owner": "@acme/payments",
"reviewBy": "2026-12-15"
}
]
}
The kind field is what stops the two categories collapsing into each other. A false-positive record asserts the tool is wrong and must cite the evidence that proves it — a measured ratio, a screen-reader transcript, a link to the manual test. An accepted-risk record admits the failure is real and is therefore reportable to anyone asking about conformance. Splitting them means a quarterly audit can answer “how many known barriers do we ship” without hand-sorting a list of rule names, and it lets the owning team be pulled from the record automatically the way assigning violation ownership with CODEOWNERS describes.
The loader turns records into scan behaviour and refuses to run on a malformed or stale list. This is the part that has to live in CI rather than in a reviewer’s memory:
// a11y/apply-suppressions.js
import { readFileSync } from 'node:fs';
const REQUIRED = ['ruleId', 'selector', 'wcag', 'kind', 'justification',
'owner', 'reviewBy'];
export function loadSuppressions(path = 'a11y/suppressions.json',
today = new Date()) {
const { records } = JSON.parse(readFileSync(path, 'utf8'));
const errors = [];
for (const [i, r] of records.entries()) {
for (const field of REQUIRED) {
if (!r[field]) errors.push(`record ${i}: missing "${field}"`);
}
// A justification short enough to be a rule name is not a justification.
if (r.justification && r.justification.length < 40) {
errors.push(`record ${i}: justification is too thin to review`);
}
if (r.kind && !['false-positive', 'accepted-risk'].includes(r.kind)) {
errors.push(`record ${i}: kind must be false-positive or accepted-risk`);
}
// An expired record fails the build. Renewing it is a reviewed commit.
if (r.reviewBy && new Date(r.reviewBy) < today) {
errors.push(`record ${i}: expired on ${r.reviewBy} (${r.ruleId})`);
}
}
if (errors.length) {
throw new Error(`invalid suppression list:\n ${errors.join('\n ')}`);
}
return records;
}
// Suppress at the node level, not the rule level: a rule stays enabled
// everywhere except the exact selectors that carry a live record.
export function filterViolations(violations, records) {
const allowed = new Set(
records.map((r) => `${r.ruleId}::${r.selector}`),
);
return violations
.map((v) => Object.assign({}, v, {
nodes: v.nodes.filter(
(n) => !n.target.some((t) => allowed.has(`${v.id}::${t}`)),
),
}))
.filter((v) => v.nodes.length > 0);
}
Note what this deliberately does not do: it never calls axe.configure() to disable a rule, and it never passes an exclude context. The rule runs everywhere, the violation is still produced, and only the specific node covered by a live record is dropped from the gating set. That means a new instance of the same rule on a different selector still fails the build — the failure mode that a blanket disableRules entry silently permits for years. It also means the raw result stays complete for the dashboard, which is what makes trend data usable when a budget is being tightened as in ratcheting violation budgets down each sprint.
Validation
The gate has to assert three separate things: no unsuppressed violation, no untriaged incomplete result, and no suppression record that has outlived its review date. Request incomplete explicitly in resultTypes, because trimming it away to reduce report size is exactly how it stops being triaged.
// a11y/gate.js — run after the page is loaded in a Playwright fixture
import { AxeBuilder } from '@axe-core/playwright';
import { loadSuppressions, filterViolations } from './apply-suppressions.js';
const TAGS = ['wcag2a', 'wcag2aa', 'wcag21aa', 'wcag22aa'];
export async function gate(page, route) {
const records = loadSuppressions(); // throws on an expired or thin record
const results = await new AxeBuilder({ page })
.withTags(TAGS)
// Keep incomplete in the payload: it is a work queue, not noise.
.options({ resultTypes: ['violations', 'incomplete'] })
.analyze();
const gating = filterViolations(results.violations, records)
.filter((v) => v.impact === 'serious' || v.impact === 'critical');
const untriaged = results.incomplete.filter(
(v) => !records.some((r) => r.ruleId === v.id),
);
return {
route,
engine: results.testEngine.version, // pin drift shows up here first
gating,
untriagedIncomplete: untriaged.map((v) => ({
id: v.id, nodes: v.nodes.length,
})),
exitCode: gating.length > 0 || untriaged.length > 0 ? 1 : 0,
};
}
A passing route prints a report with both lists empty; the interesting case is the first run against a real page, which looks like this and is the point at which most teams discover their incomplete backlog:
route: /pricing engine: 4.10.2
gating violations: 0
untriaged incomplete:
color-contrast 14 nodes
aria-hidden-focus 2 nodes
scrollable-region-focusable 1 node
exit code: 1
Fourteen unresolved contrast pairs is not a passing page — it is fourteen open questions. Resolve each one by inspection: measure the ratio against the rendered pixels, add a scrim where the ratio is genuinely borderline, and add a false-positive record with the measured value where the composition already satisfies WCAG 2.2 SC 1.4.3. The two focus-related entries are usually genuine and belong in the fix queue rather than the record file.
Edge Cases and Conditional Guards
aria-hidden-focuson an animated overlay. A closing modal keepsaria-hidden="true"while its focusable children are still in the DOM for the duration of the exit transition, so a scan that lands mid-animation reports a real rule against a state no user reaches. Wait for the transition to finish rather than recording a suppression, because the same rule catches genuine focus traps.- Shadow roots and cross-origin frames. axe reports
incompletefor content it cannot enter, including closed shadow roots and third-party iframes. That is not a false positive and not a pass — it is out of scope, and it needs the traversal setup in scanning Shadow DOM and iframes with axe-core before any verdict is meaningful. - Live-region rules. No static check can prove an announcement was heard, so an
aria-livecontainer that looks correct will always pass structurally and may still be silent in practice. Verify these with a runtime assertion of the kind described in verifying live-region announcements in automated tests rather than trusting a green result.
Pipeline Impact
The suppression loader gives the job two distinct non-zero exits, and keeping them distinct is worth the extra code. An exit driven by gating.length means a developer introduced a barrier and the fix belongs in their branch. An exit driven by loadSuppressions() throwing means a record expired and the fix is a review conversation — nothing about the branch is wrong. Reporting both as “accessibility failed” trains people to re-run the job hoping it goes away. Emit the expiry failure with its own message and its own annotation, and set the review dates so they land mid-sprint rather than on a release day.
Because the raw result is preserved before filtering, the artifact uploaded from the job is a complete record: every violation, every suppressed node with the record that suppressed it, and every unresolved incomplete entry. That is the payload a compliance report is generated from, and it is also what makes the suppression list auditable — a reviewer can diff last month’s artifact against this month’s and see exactly which records were renewed, which were retired because the underlying markup was fixed, and which quietly grew a second selector. Records that keep being renewed without change are the strongest signal that an accepted-risk entry has become permanent and needs escalating rather than re-dating.
Common Pitfalls
- Filtering on
violations.lengthalone, which converts everyincompleteresult into a silent pass and makes contrast coverage on image-heavy pages effectively zero. - Disabling
color-contrastglobally because a hero section is unresolvable, which also stops the rule checking the eleven thousand words of body copy where it works perfectly. - Using
excludecontext selectors as the suppression mechanism, so every future rule on that subtree is suppressed too, including ones that did not exist when the entry was written. - Writing
aria-hidden="true"on an element that still contains a focusable control, which converts a cosmetic complaint into a real WCAG 2.2 SC 4.1.2 (Name, Role, Value) failure for keyboard users. - Recording a suppression against a generated class name or an
nth-childselector, which stops matching after the next refactor and silently re-opens the failure — or worse, starts suppressing a different element.
FAQ
Is an incomplete result ever safe to ignore in bulk?
Only for rules whose incompleteness is structural rather than page-specific, such as content inside a cross-origin frame you do not control. Even then, record the decision once with a rule ID and a reason so the count is explained rather than absent. Bulk-ignoring color-contrast incompleteness is never safe, because the same rule ID covers both the unresolvable hero and every ordinary paragraph.
How long should a review date be? Ninety days is a workable default: long enough that a team is not re-litigating the same record every sprint, short enough that an expired entry surfaces inside the same planning cycle that created it. Tie the date to a real event where one exists — the quarter a legacy table is being replaced, the release that ships a new scrim token — because a date derived from a plan gets renewed with the plan instead of by reflex.
Who should be allowed to approve a new suppression record?
Whoever is accountable for the criterion, which in practice means an accessibility specialist for accepted-risk entries and a normal code reviewer for false-positive entries with measured evidence attached. The asymmetry is deliberate: claiming the tool is wrong is a factual assertion that evidence can settle, while accepting a real barrier is a risk decision that needs someone able to carry it.
Related
- axe-core Configuration & Setup — the parent guide covering rule tags, baselines and exit-code mapping.
- How to Configure axe-core for React and Vue Applications — the framework timing fixes that remove a whole class of apparent false positives before triage begins.
- CI/CD Integration & Automated Quality Gating — the section covering how these exit codes, artifacts and annotations become a merge policy.