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.

What belongs inside the audit's scope Four bands. Your component's own markup is in scope and every finding should be fixed. A third-party widget is out of scope and should be excluded by selector. Storybook's own interface chrome is out of scope, handled by scoping the audit to the story root. Page-level landmarks are absent by design when a component renders in isolation and are not a component defect. What an axe run should and should not judge Your component's markup in scope — fix every finding A third-party widget out of scope — exclude by selector Storybook's own chrome out of scope — scope to the root Page-level landmarks absent by design in isolation Excluding a node is narrower — and safer — than disabling a rule

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

Signal recovered by scoping the audit A log comparing two runs. Before scoping, forty-one violations are reported across forty-eight stories: eighteen from Storybook's own chrome, twelve for a missing main landmark that is absent by design in isolation, seven from a third-party date picker, and four genuine button-name failures in your own components. After scoping to the story root and excluding the third-party subtree, four violations remain — all of them real. test-storybook — before and after scoping before: 41 violations across 48 stories region 18x Storybook chrome landmark-one-main 12x absent in isolation aria-allowed-attr 7x third-party date picker button-name 4x your components after scoping + excludes: 4 violations

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

Five ways to narrow an audit, ranked by cost Five rows. Scoping the run to the Storybook root applies to the whole run, still checks your markup and should always be the baseline. An exclude selector removes one subtree while checking everywhere else, which suits a third-party widget. Disabling a rule turns it off everywhere and should be reserved for rules outside your conformance target. A per-story override affects one story only and suits an isolation artefact. Disabling accessibility checks for a story removes all rules and is almost never justified. Technique Scope Still checks your markup? Use when Scope to #storybook-root whole run yes always, as a baseline exclude selector one subtree yes, everywhere else third-party widget rules.enabled: false one rule, everywhere no, for that rule rule not in your target Per-story override one story yes, other stories isolation artefact a11y.disable one story, all rules no almost never
  • 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.