How to Configure axe-core for React and Vue Applications

A component framework never hands the browser a finished document. It produces one incrementally, and the accessibility tree that axe-core walks is only trustworthy at specific moments in that process. This guide is part of axe-core Configuration & Setup, and it covers where to attach a scan inside the React and Vue update cycles, how to keep @axe-core/react and vue-axe out of a production bundle, how to debounce a re-render storm so the console stays readable, and how the resulting developer-facing signal relates to the scan that actually gates a merge.

Root Cause

React splits an update into a render phase and a commit phase, and only the second one touches the DOM. Render-phase work is speculative: concurrent rendering can abandon a render half-finished, and StrictMode deliberately invokes component bodies twice in development to surface impure code. An axe.run() called from a component body, a useMemo, or a render return value therefore reads the previous DOM while the next props are in scope, which produces violations attributed to markup that has already been replaced. Moving the call into useLayoutEffect is better but still wrong: layout effects fire inside the commit phase, after this subtree’s mutations but before sibling subtrees have been committed and before the browser has painted, so the scan sees a half-updated document. The first correct attachment point is a passive effect at the root of the tree, and the reliably correct point is an idle callback after that passive effect has flushed.

Vue 3 has the same shape with different names. Reactive updates are pushed into a job queue that Vue flushes on a microtask, so several state writes in one tick collapse into one patch. A watcher created with the default flush: 'pre' runs before the component patches; flush: 'post' runs after; nextTick() resolves once the whole queue has drained. The Vue analogue of “after commit” is therefore await nextTick(), and the analogue of “mid-render” is any synchronous DOM read inside a computed property or a pre-flush watcher. Vue’s updated hook fires after a component’s own patch, and because a parent’s updated runs after its children’s, scanning from the root component’s updated is the closest equivalent to React’s root passive effect. Framework-set ARIA attributes land in that same window, which is why attribute-timing problems and scan-timing problems are usually the same bug — the mechanics of resolving them are covered in handling dynamic ARIA states in modern JavaScript frameworks.

The second half of the problem is cost. axe-core is a synchronous DOM traversal plus rule evaluation; on a moderately sized route it occupies the main thread for 150–600 ms. A controlled text input commits once per keystroke, so a developer typing at eight characters a second queues scans faster than they can finish, each one re-reporting the identical set of nodes. Without a debounce the console fills with the same three warnings twenty-four times and the dev server feels broken. The third half of the problem is shipping: axe-core is roughly 600 KB unminified and assigns itself to window.axe as a module side effect, so a bundler cannot drop it merely because nothing reads its export. @axe-core/react goes further and patches React DOM internals at import time. Only a dynamic import() sitting behind a build-time-evaluable flag keeps either one out of the production graph; a check against a runtime variable does not.

Where a scan may attach in the React work loop Three phases run left to right: the render phase which may be discarded or double-invoked, the commit phase in which the DOM is mutated, and passive effects followed by idle time. A rejected scan point sits above the render phase and the accepted scan point sits after the idle region. One React update, start to paint axe.run in render reads the old DOM render phase discardable, runs twice commit phase DOM mutated per subtree passive effects, then idle tree is final and painted useLayoutEffect fires here — siblings still pending debounced axe.run one scan per burst
The only attachment point that always sees a complete tree is after passive effects have flushed, which is where both @axe-core/react and a hand-rolled Vue hook place their debounced call.

Configuration

Start with a single options module that both the dev loop and the CI job import. Two copies of the tag list drift within a sprint, and a rule that a developer never sees locally but that fails their pull request is the fastest route to the gate being treated as noise.

// a11y/axe-options.js — the one source of truth for both runners
export const wcagTags = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'];

// axe.run() options form: `rules` is an object keyed by rule id.
export const runOptions = {
  runOnly: { type: 'tag', values: wcagTags },
  resultTypes: ['violations'], // the dev loop never reads passes/inapplicable
};

// Impacts that are allowed to fail a build. Everything else is reported only.
export const gatingImpacts = new Set(['serious', 'critical']);

For React, wire @axe-core/react in the client entry point, before the first render() call, inside a branch the bundler can delete. import.meta.env.DEV is replaced with a literal false in a Vite production build, so the whole block — including the dynamic import specifier — is unreachable and the axe-core module never enters the graph. With webpack or Next.js the equivalent literal is process.env.NODE_ENV !== 'production'.

// src/main.jsx — dev-only axe wiring for a React 18 or 19 app
import React from 'react';
import ReactDOM from 'react-dom';              // @axe-core/react patches this
import { createRoot } from 'react-dom/client'; // rendering still uses the root API
import App from './App.jsx';

const root = createRoot(document.getElementById('root'));

async function start() {
  if (import.meta.env.DEV) {
    const { default: axe } = await import('@axe-core/react');
    await axe(
      React,
      ReactDOM,
      1000, // debounce in ms: wait for the commit queue to be quiet this long
      // 4th argument is passed to axe.configure(), where `rules` is an ARRAY.
      // Using the axe.run() object form here silently does nothing.
      { rules: [{ id: 'region', enabled: false }] },
      // 5th argument is the axe.run() context: keep dev-server chrome out.
      { exclude: [['#vite-error-overlay'], ['[data-vite-dev-id]']] },
    );
  }
  root.render(<App />);
}

start();

Three details in that call matter. ReactDOM must come from react-dom, not react-dom/client, because the object @axe-core/react instruments is the legacy namespace even in a createRoot application. The fourth argument reaches axe.configure(), whose rules field is an array of rule descriptors, while the runOptions module above uses the keyed-object form that axe.run() expects — passing one shape where the other is expected fails silently and is the single most common reason a dev-time disable appears to be ignored. The region rule is turned off locally because developers routinely mount a route in isolation without the app shell’s landmarks; the CI scan runs against the full page and keeps it enabled.

Vue has vue-axe, which follows the same model: a plugin registered on the app instance that scans after the update queue drains. Registering it by hand is worth doing once, because the twenty lines below make the timing and the deduplication explicit rather than implicit, and they are trivial to point at a different root or a different debounce.

// src/dev/axe-plugin.js — dev only; never imported from a production entry
import { nextTick } from 'vue';
import { runOptions } from '../../a11y/axe-options.js';

let timer = null;
let axePromise = null;
const reported = new Set(); // rule id + target: report each pairing once

async function scan(rootSelector) {
  // Lazy import keeps axe-core out of any graph that does not call scan().
  axePromise ??= import('axe-core').then((m) => m.default ?? m);
  const axe = await axePromise;
  await nextTick(); // let Vue's job queue drain before touching the DOM
  const root = document.querySelector(rootSelector);
  if (!root) return;
  const { violations } = await axe.run(root, runOptions);
  for (const violation of violations) {
    for (const node of violation.nodes) {
      const key = `${violation.id}::${node.target.join(' ')}`;
      if (reported.has(key)) continue; // suppress the re-render storm duplicate
      reported.add(key);
      console.warn(`[a11y] ${violation.id} (${violation.impact})`, node.target);
    }
  }
}

export const DevAxePlugin = {
  install(app, { selector = '#app', delay = 800 } = {}) {
    const schedule = () => {
      clearTimeout(timer); // any further update restarts the quiet window
      timer = setTimeout(() => scan(selector), delay);
    };
    app.mixin({ mounted: schedule, updated: schedule });
  },
};

The debounce and the reported set solve two different duplication problems. The timer collapses a burst of commits into one scan, which is a performance fix. The set collapses repeated reports of the same rule on the same target across separate bursts, which is a readability fix — without it, every route change re-announces the four violations the developer has already decided to leave alone. Register the plugin the same way, behind a build-time flag:

// src/main.js — Vue 3 entry; the plugin only exists in a dev build
import { createApp } from 'vue';
import App from './App.vue';
import router from './router.js';

async function start() {
  const app = createApp(App);
  app.use(router);
  if (import.meta.env.DEV) {
    const { DevAxePlugin } = await import('./dev/axe-plugin.js');
    app.use(DevAxePlugin, { selector: '#app', delay: 800 });
  }
  app.mount('#app');
}

start();
Effect of an 800 ms debounce on one typing burst Five horizontal bars measured from the same burst of twenty-four React commits: commits in the burst, scans without a debounce, console lines without a debounce, scans with a debounce, and console lines after deduplication. One 700 ms typing burst in a controlled input commits in the burst 24 scans, no debounce 24 console lines, no debounce 72 scans, 800 ms debounce 1 console lines, deduped 3 rose = every commit scanned; green = one debounced scan plus a rule-and-target dedupe key
The three real violations on the route never change during the burst; everything above three console lines is duplicate output that trains developers to ignore the channel.

Validation

Two properties need proving, and both are cheap enough to run in the same job that builds the app. The first is that no part of axe reached the production output. Grep the built assets rather than trusting the bundler, because a stray static import in a component file will happily pull axe-core in behind an unused export.

#!/usr/bin/env bash
set -euo pipefail

npm run build

# axe-core keeps its own version banner and the string "axe-core" in the
# bundled source, so a plain grep over the emitted chunks is sufficient.
if grep -rqE 'axe-core|@axe-core/react|vue-axe' dist/assets/; then
  echo "FAIL: an axe module reached the production bundle"
  grep -rlE 'axe-core|@axe-core/react|vue-axe' dist/assets/
  exit 1
fi

echo "OK: production bundle is axe-free"
du -sk dist/assets | awk '{ print "emitted JS/CSS: " $1 " KiB" }'

The second property is that the debounce actually collapses a burst. Assert it with fake timers so the test does not depend on machine speed; the mock records one entry per axe.run() call, and the expectation is one entry for twenty updates.

// src/dev/axe-plugin.test.js — proves one scan per burst, not one per update
import { describe, expect, it, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { defineComponent, ref } from 'vue';

const runs = [];
vi.mock('axe-core', () => ({
  default: { run: async () => { runs.push(1); return { violations: [] }; } },
}));

const Counter = defineComponent({
  setup: () => ({ n: ref(0) }),
  template: '<button @click="n += 1">{{ n }}</button>',
});

describe('DevAxePlugin', () => {
  it('scans once for twenty updates inside the debounce window', async () => {
    vi.useFakeTimers();
    const { DevAxePlugin } = await import('./axe-plugin.js');
    const opts = { global: { plugins: [[DevAxePlugin, { delay: 800 }]] } };
    const wrapper = mount(Counter, opts);
    for (let i = 0; i < 20; i += 1) {
      await wrapper.find('button').trigger('click');
      vi.advanceTimersByTime(30); // keystroke-speed updates, well inside 800 ms
    }
    await vi.advanceTimersByTimeAsync(800); // now let the quiet window elapse
    expect(runs).toHaveLength(1);
  });
});

Expected output on a passing run is a single line per test file plus the bundle report from the shell script: 1 passed from Vitest and OK: production bundle is axe-free from the build check. If the Vitest assertion reports 20 rather than 1, the schedule function is being recreated per component instance instead of sharing the module-level timer.

Edge Cases and Conditional Guards

  • StrictMode double invocation. @axe-core/react reports against the committed DOM, so the double render itself is harmless — but a component that mounts, unmounts and remounts under StrictMode’s effect-replay produces two mounted calls in the Vue plugin. The shared timer absorbs them; a per-instance timer would not.
  • Portals and Teleport. React portals and Vue <Teleport to="body"> render outside the element passed as the scan root, so a modal mounted to document.body is invisible to a scan scoped to #app. Widen the root to document when a dialog is open, or scan the teleport target separately rather than assuming the container covers everything.
  • Async boundaries. A tree inside Suspense or an async setup() still carries aria-busy="true" when the debounce fires on a slow connection, and rules that depend on final content — accessible names, heading order — will report against the fallback. Guard the scan with a check that no [aria-busy="true"] ancestor remains, and re-schedule if one does.

Pipeline Impact

The dev-loop scan has no exit code and must never acquire one. Its job is to put a violation in front of the person who wrote the markup within a second of them writing it; its scope is whatever route the developer happens to be on, its DOM contains dev-server chrome, and its results are not recorded anywhere. The CI scan is the opposite on every axis: fixed route list, built output, pinned axe-core version, JSON artifact, and an exit code derived from gatingImpacts. Wiring that second scan is a separate job covered in integrating axe-core Playwright into an existing project, and the component-level equivalent for a Cypress suite is in configuring cypress-axe for component testing.

What connects them is the shared options module, not shared infrastructure. Because both import wcagTags, a rule that fires in CI also fires locally, so the gate never surprises anybody. Because only CI reads gatingImpacts, a moderate finding is visible in the console without being able to block a merge. When a rule does fire in CI and the local run disagrees, the difference is almost always the dev-only configure() overrides or the excluded dev-server nodes — check those two before assuming a scanner defect, and if the finding survives that check, triage it with the process in reducing false positives in automated accessibility scanners rather than adding a local disable.

Dev-loop signal versus the CI gate The left card describes the development scan: debounced per commit, console output, never fails a build, dev DOM with overlays excluded. The right card describes the CI scan: once per pull request, JSON artifact, exit one on serious findings, built output with a pinned version. A shared options module below feeds both. Dev loop in the browser CI gate on the pull request fires per debounced commit output: console.warn only never fails a build dev DOM, overlays excluded fires once per commit pushed output: JSON + annotations exit 1 on serious+ built output, pinned version a11y/axe-options.js — same tags and impacts imported by both runners
The two scans differ deliberately in trigger, output and authority; the only thing they must share is the rule selection, which is why it lives in its own module.

The same options module is what makes a component-library audit consistent with the application gate, which matters most for teams whose shared components are scanned separately in Storybook — see auditing a component library with Storybook and axe for that half of the setup.

Common Pitfalls

  • Importing @axe-core/react at the top level of a component file “just for now”: the module has side effects, so it survives tree-shaking and ships to users even if the call is behind a runtime flag.
  • Passing ReactDOM from react-dom/client into axe(React, ReactDOM, 1000), which produces a plugin that installs cleanly and then never reports anything.
  • Using the axe.run() object form of rules in the configure() argument, so a local rule disable appears to be ignored and gets “fixed” by disabling the rule globally instead.
  • Creating the debounce timer inside the plugin’s install closure per component instance rather than once per module, which restores the one-scan-per-commit behaviour the debounce was added to remove.
  • Scoping the dev scan to the framework mount node and then wondering why no dialog violation ever appears, because every dialog is teleported to document.body.

FAQ

Does the debounce hide a violation that only exists for one frame? It hides transient states by design, and that is usually correct: a loading skeleton that lacks a heading for 200 ms is not a defect a user can encounter. What it can hide accidentally is a violation in a component that unmounts before the window elapses, such as a toast with a two-second lifetime. Scan those in a component test where you control the lifetime, rather than lowering the debounce for the whole application.

Can the same setup run inside Jest or Vitest component tests instead? Yes, and it should — a jsdom test with jest-axe or a direct axe.run(container) gives a deterministic per-component result with no timing at all, because the render call returns after the commit. The dev-loop hook covers the integrated page that no component test assembles; the two are complements. Keep both on the same tag list so a component that passes in isolation cannot fail only in the page scan for rule-selection reasons.

Why not just run the CI scan locally with a watch flag? Because it costs a build and a browser launch per change, which is 20–60 seconds against the 800 ms the in-page hook takes. The dev hook exists to shorten the feedback loop, not to replicate the gate; when a developer wants gate-identical output they should run the CI command directly against a built preview, and the shared options module means the rule set will match.