Skip to content

Dev-Only Accessibility Checker: axe-core Loads in Zero Bytes in Production

12/16/2025Web DevelopmentKUIreact6 min read

Dev-Only Accessibility Checker: axe-core Loads in Zero Bytes in Production

axe-core is 340kB. It doesn't enter the production bundle because a dynamic import plus a process.env guard work together.

Accessibility checks belong in development, not production. The problem: import axe from 'axe-core' ships 340kB to every user — code that will never run in production. kui-react's useA11yCheck hook solves this with a two-layer guard.

The Hook

// libs/hooks/useA11yCheck.ts
export function useA11yCheck(
  ref: React.RefObject<HTMLElement | null>,
  enabled = true,
) {
  useEffect(() => {
    if (process.env.NODE_ENV !== 'development') return;
    if (!enabled) return;
    if (!ref.current) return;

    const node = ref.current;
    let cancelled = false;

    import('axe-core').then(({ default: axe }) => {
      if (cancelled) return;
      axe.run(node).then((results) => {
        if (cancelled) return;
        results.violations.forEach((v) => {
          console.warn(`[a11y] ${v.id}: ${v.description}`, v.nodes);
        });
      });
    });

    return () => {
      cancelled = true;
    };
  }, [ref, enabled]);
}

Two guards, two different mechanisms.

Guard 1: process.env.NODE_ENV — Build-Time Elimination

if (process.env.NODE_ENV !== 'development') return;

Next.js and Vite inline process.env.NODE_ENV at build time:

// After production build:
if ('production' !== 'development') return;
// → always true → the entire if block becomes dead code

The bundler sees this as unreachable and eliminates the entire block. The import('axe-core') call that follows the return is never reached — tree-shaking removes it from the bundle entirely.

In production: axe-core contributes zero bytes.

Guard 2: Dynamic Import — Runtime Separation

import('axe-core').then(({ default: axe }) => {

A static import at the top of the file would include axe-core in the bundle regardless of the NODE_ENV guard:

// ❌ Static import — always in bundle
import axe from 'axe-core';

export function useA11yCheck(...) {
  if (process.env.NODE_ENV !== 'development') return;
  // axe is already bundled — 340kB wasted
}

Dynamic import places axe-core in a separate chunk. The chunk is only fetched when the import is actually executed — which only happens when the NODE_ENV guard passes (i.e., in development).

The two guards reinforce each other: the NODE_ENV guard eliminates the chunk reference at build time; the dynamic import prevents the network request at runtime.

cancelled Ref — Race Condition Guard

axe-core runs asynchronously. The component may unmount before it completes:

let cancelled = false;

import('axe-core').then(({ default: axe }) => {
  if (cancelled) return;  // component unmounted before import resolved
  axe.run(node).then((results) => {
    if (cancelled) return;  // component unmounted before axe.run completed
    results.violations.forEach((v) => {
      console.warn(`[a11y] ${v.id}:`, v.nodes);
    });
  });
});

return () => { cancelled = true; };  // cleanup on unmount

The check runs at both async boundaries: when the dynamic import resolves and when axe.run completes. let cancelled = false (not useRef) is intentional — each effect invocation needs its own fresh cancelled flag.

What Gets Reported

results.violations.forEach((v) => {
  console.warn(`[a11y] ${v.id}: ${v.description}`, v.nodes);
});

axe-core returns a violations array. Each violation has:

  • id: the rule name (color-contrast, label, aria-required-attr)
  • description: human-readable explanation
  • nodes: clickable DOM node references in browser DevTools

Output appears in the browser console during development. No UI components are touched, no state is modified.

Usage

function MyForm() {
  const formRef = useRef<HTMLDivElement>(null);
  useA11yCheck(formRef);  // runs automatically in dev

  return (
    <div ref={formRef}>
      <label htmlFor="name">Name</label>
      <input id="name" type="text" />
    </div>
  );
}

In production builds, the hook is compiled out entirely — its presence in the source has no runtime cost.

What It Catches

axe-core tests against WCAG 2.1 AA rules. Common violations:

  • label<input> has no associated <label>
  • color-contrast — text/background contrast ratio insufficient (AA: 4.5:1)
  • aria-required-attr — required ARIA attribute missing for the element's role
  • button-name<button> has no accessible name
  • image-alt<img> missing alt attribute
  • duplicate-id — same id on multiple elements

These aren't linting checks — axe-core runs against the rendered DOM, not source code. A component can pass linting and still fail accessibility checks based on how it renders at runtime.

Limitations

axe-core doesn't catch everything. About 57% of WCAG 2.1 criteria are automatically testable (axe-core covers most of that 57%). The rest requires manual testing: is focus order logical? Does the screen reader flow make sense? Does keyboard navigation work?

axe.run(node) tests only the specific DOM node. To test an open modal, pass the modal's root node. To test the full page, pass document.body.

Running only in development means production issues can slip through. For CI-based accessibility testing, playwright-axe or cypress-axe runs against a real browser in a real environment.

Zero-Cost Layer

The hook adds accessibility checking with no production overhead. Adding useA11yCheck(divRef) to a component costs nothing in production — the line is compiled out. In development, it provides a free WCAG audit on every render cycle.

Applied to every component in a library: developers get instant feedback on accessibility issues before they reach code review. Merging a component with violations requires consciously ignoring the console warning — which is much harder to miss than a checklist item.

Business Impact

Accessibility failures found in production mean support tickets, user complaints, and potential legal risk. Failures found in development are five-minute fixes. The earlier in the pipeline, the cheaper.

For government, healthcare, and education projects, WCAG compliance is increasingly a contractual requirement. useA11yCheck makes the audit continuous rather than a pre-launch scramble.

Something to Try

Add axe-core as a devDependency and drop this hook into your project:

export function useA11yCheck(ref: React.RefObject<HTMLElement | null>) {
  useEffect(() => {
    if (process.env.NODE_ENV !== 'development') return;
    if (!ref.current) return;
    let cancelled = false;
    import('axe-core').then(({ default: axe }) => {
      if (cancelled) return;
      axe.run(ref.current!).then((results) => {
        if (cancelled) return;
        results.violations.forEach((v) =>
          console.warn(`[a11y] ${v.id}`, v.nodes)
        );
      });
    });
    return () => { cancelled = true; };
  }, [ref]);
}

Add it to the component you're most confident is accessible. Open the browser console. See what comes back. Most projects surface at least one violation on the first run.

Related Articles

Same Category

Comments (0)

Newsletter

Stay updated! Get all the latest and greatest posts delivered straight to your inbox