Excluding third-party nodes from axe-core scans
This page is part of the accessibility testing section of the Storybook isolation workflows guide, which covers auditing components where they render in isolation.
The specific problem: axe reports dozens of violations, and most of them come from a third-party date picker, Storybook’s own toolbar, or the absence of page-level landmarks. The real findings are buried, so nobody reads the report.
Problem statement
An audit is only useful if its output is actionable. A run reporting forty violations, of which four are yours, trains the team to skim — and the four that mattered go unfixed alongside the thirty-six that never could be.
The symptom is an accessibility check that everyone agrees is important and nobody acts on, usually accompanied by a rule or two disabled globally in an attempt to quieten it.
Root cause
By default axe audits the whole document. In Storybook that document contains the preview iframe’s contents and, depending on how the audit is invoked, parts of the manager interface. It also contains any third-party widget your component renders, whose markup you cannot change without forking the dependency.
Separately, a component rendered in isolation legitimately lacks page structure. Rules such as landmark-one-main and page-has-heading-one describe a page, and a Button is not a page. Their findings are true and irrelevant.
Minimal reproduction
// audits everything on the page, including things you do not own
async postVisit(page) {
await checkA11y(page); // no context, no excludes
}
Step-by-step fix
1. Scope the audit to the story root
// .storybook/test-runner.ts
import { injectAxe, checkA11y } from 'axe-playwright';
export default {
async preVisit(page) {
await injectAxe(page);
},
async postVisit(page) {
await checkA11y(page, '#storybook-root', { detailedReport: true });
},
};
What this does: limits the audit to what the story rendered. Storybook’s toolbar, sidebar and addon panels are no longer part of the result.
2. Exclude third-party subtrees by selector
await checkA11y(page, {
include: [['#storybook-root']],
exclude: [['.react-datepicker'], ['[data-vendor="stripe"]']],
}, { detailedReport: true });
What this does: removes markup you cannot change while leaving every rule active everywhere else — which is what makes this narrower and safer than disabling the rules the widget trips.
3. Narrow the rule set to your conformance target
// preview.ts — one place, applied to the panel and the runner alike
export const parameters = {
a11y: {
options: { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21aa'] } },
config: {
rules: [
{ id: 'landmark-one-main', enabled: false }, // page-level, not component-level
{ id: 'page-has-heading-one', enabled: false },
{ id: 'region', enabled: false },
],
},
},
};
What this does: turns off exactly the rules that describe a page rather than a component, and states the WCAG level you are actually claiming.
4. Use a per-story override for genuine one-offs
export const InsideADialog: Story = {
parameters: {
a11y: { config: { rules: [{ id: 'aria-dialog-name', enabled: false }] } },
},
};
What this does: keeps the exception next to the story it applies to, where a reviewer will see it, instead of widening a global rule list.
5. Re-run and read what is left
npm run test-storybook -- --url http://127.0.0.1:6006
Verification
The verification that matters is not that the number went down — it is that every remaining finding is one you would act on. Introduce a deliberate defect, such as removing an icon button’s label, and confirm the run still catches it. A scoped audit that no longer catches real problems has been narrowed too far.
Edge cases and caveats
-
Excluding by selector requires a stable selector. A vendor class name can change on a minor upgrade, silently re-including the subtree. Prefer a wrapper you control, with your own
data-attribute, around the third-party component. -
Shadow DOM. axe traverses open shadow roots, so a web component’s internals are audited. If those internals are third-party, exclude the host element — you cannot select into a closed root.
-
Excluding too much. Wrapping a broad container in an exclusion removes your own markup along with the vendor’s. Keep the excluded subtree as tight as the widget itself.
Keeping the exclusion list honest
Every exclusion is a small, permanent reduction in what the audit can see, and the cost is paid silently. A list that starts as three vendor widgets becomes, over a couple of years, the reason a genuine defect ships — not because anyone decided to stop checking that area, but because nobody revisited the decision.
Three habits keep the list from decaying into a blind spot.
Give every entry an owner and a reason. A selector with no comment is indistinguishable from a mistake. // react-datepicker: aria-allowed-attr, upstream issue #4412 tells the next reader what it is waiting on and lets them check whether that issue has since closed.
Prefer a wrapper you own to a vendor selector. [data-a11y-exempt="datepicker"] on an element in your own code survives a dependency upgrade, is greppable, and shows up in code review when someone adds one. A vendor class name does none of those things and can silently stop matching.
Retire entries on a schedule. Once a quarter, delete the list and run the suite. Whatever fails goes back, with its comment refreshed; whatever passes was fixed upstream and can stay gone. The exercise takes an hour and is the only thing that reliably shrinks the list.
The underlying principle is that an exclusion is a debt, not a configuration. It is worth taking on to keep the report readable, and it needs paying down like any other.
FAQ
Should I exclude a third-party widget or report the violation upstream?
Both. Exclude it so your own findings are readable, and open an issue upstream so the defect is someone’s problem rather than nobody’s. Record the exclusion with a comment naming the issue, so it can be removed when the fix lands instead of outliving the problem indefinitely.
Is disabling region and landmark-one-main cheating?
No — those rules describe a document, and a story is not a document. A component that renders in isolation has no main element because the application supplies it. The honest way to cover page-level structure is a separate audit against the real application, where those rules apply and can be enforced.
How do I make sure exclusions do not accumulate?
Keep them in one file, require a comment on each, and review the list on a schedule. An exclusion list that grows monotonically eventually excludes the components you care about. A short periodic pass — remove each entry, run, see what breaks — usually retires several of them, because the upstream widget was fixed and nobody noticed.
Can I fail the build on new exclusions rather than reviewing them?
Yes, and it works well: keep the exclusions in a dedicated module, and add a test asserting the list’s length against a recorded number. Adding an entry then fails until someone updates the number in the same commit, which puts the decision in the diff where a reviewer will see it — rather than in a config file nobody opens.
Related
- Accessibility Testing — the parent section on auditing components in isolation
- Configuring the Storybook a11y Addon for WCAG Checks — scoping which rules and WCAG tags run
- Fixing Colour Contrast Violations Flagged by axe-core — the most common finding, and why it is usually a token
- Test-Runner Automation — the postVisit hook these audits run inside