testing and automating accessibility

Writing Custom axe-core Rules

axe-core's built-in rules cover the machine-checkable parts of WCAG, but they can't know your design system's conventions: that every icon-only button must carry a specific data attribute, that your Modal component must always render an aria-labelledby, or that a custom widget must expose a particular role. axe.configure() lets you register your own checks and rules so these project-specific requirements run through the same engine—and the same violations/incomplete/passes pipeline—as the standard ones. This guide, part of Automated Accessibility Testing with axe-core, shows how to author, scope, and test a custom rule, and when a lint rule or jest-axe assertion is the better tool.

Prerequisites

  • A working axe-core integration (browser, @axe-core/react, jest-axe, or Playwright).
  • A concrete, falsifiable requirement—something a small evaluate function can decide true/false from the DOM.
  • Familiarity with the result model: axe.configure() rules emit the same violations / incomplete / passes buckets covered in the guide.

Anatomy: Checks vs Rules

axe separates two concepts, and axe.configure() registers both:

  • A check is the unit of logic. Its evaluate function receives a DOM node and returns true (passed), false (failed), or undefined (incomplete—needs review). This is where your actual test lives.
  • A rule binds one or more checks to a set of nodes via a CSS selector (and optional matches predicate), and carries metadata: id, tags, and the help text shown in reports.

A rule passes a node only if its checks resolve favourably; a single failing any/all check produces a violation. Mapping that to 4.1.2 Name, Role, Value: a check that returns false when an interactive element lacks a computed accessible name behaves exactly like axe's own name checks.


Registering a Custom Check and Rule

Below is a complete, project-specific rule: every icon-only button in our design system is marked data-icon-button, and must expose an accessible name (via aria-label, aria-labelledby, or visually-hidden text). Built-in button-name covers generic buttons, but we want a dedicated rule with our own help text and tag so violations are obviously ours in reports.

// axe-custom-rules.ts
import axe from 'axe-core';

axe.configure({
  // 1) The check: the actual decision logic.
  checks: [
    {
      id: 'icon-button-has-name',
      // evaluate runs in the page context against each matched node.
      evaluate(node: HTMLElement) {
        // axe.commons exposes the same accessible-name computation
        // the engine uses for 4.1.2 Name, Role, Value.
        const name = (axe as any).commons.text.accessibleText(node);
        return typeof name === 'string' && name.trim().length > 0;
      },
      // Message shown for pass/fail in the result data.
      metadata: {
        impact: 'serious',
        messages: {
          pass: 'Icon button exposes an accessible name.',
          fail: 'Icon-only button has no accessible name (aria-label, aria-labelledby, or visually-hidden text).',
        },
      },
    },
  ],

  // 2) The rule: bind the check to the nodes it should run on.
  rules: [
    {
      id: 'ds-icon-button-name',
      // Only our design-system icon buttons match.
      selector: 'button[data-icon-button], [role="button"][data-icon-button]',
      // Optional secondary predicate for finer matching.
      matches: (node: HTMLElement) => node.querySelector('svg') !== null,
      // Tag it so teams can include/exclude it via runOnly.
      tags: ['cat.name-role-value', 'best-practice', 'ds-internal'],
      // 'any' = passes if any listed check passes.
      any: ['icon-button-has-name'],
      metadata: {
        description: 'Design-system icon buttons must have an accessible name.',
        help: 'Add aria-label or visually-hidden text to icon-only buttons.',
        helpUrl: 'https://internal.docs/a11y/icon-button-name',
      },
    },
  ],
});

Run it like any other rule—target your custom tag to run only your rules, or merge it into a full audit:

// Run only design-system rules during a focused check.
const results = await axe.run(document, {
  runOnly: { type: 'tag', values: ['ds-internal'] },
});
console.log(results.violations.filter((v) => v.id === 'ds-icon-button-name'));

axe.configure() is global and replaces configuration, so call it once at setup. Re-running it discards prior custom config; in tests, use axe.reset() between suites if you reconfigure.


Enabling and Disabling Rules

You don't always need a new check to change behaviour. axe.configure() (and run options) can toggle existing rules and adjust their tags:

import axe from 'axe-core';

axe.configure({
  rules: [
    // Turn ON a best-practice rule that's off by default.
    { id: 'region', enabled: true },
    // Turn OFF a rule that conflicts with a known, documented exception.
    { id: 'duplicate-id', enabled: false },
    // Re-tag an existing rule so it runs under your gate's tag set.
    { id: 'color-contrast', tags: ['wcag2aa', 'wcag21aa', 'ds-internal'] },
  ],
});

// Per-run override without changing global config:
await axe.run(document, { rules: { 'region': { enabled: false } } });

Prefer per-run overrides for one-off scoping and reserve axe.configure() for permanent, project-wide policy. Document every disable with a reason and owner so it surfaces in review.


Testing the Custom Rule

A custom rule is code; it needs its own tests against known-good and known-bad DOM. Drive it through jest-axe or the raw API so a logic bug doesn't silently pass everything.

// ds-icon-button-name.test.ts
import axe from 'axe-core';
import './axe-custom-rules'; // registers the rule via axe.configure()

function mount(html: string) {
  document.body.innerHTML = html;
  return document.body;
}

it('fails an icon button with no accessible name', async () => {
  mount(`<button data-icon-button><svg aria-hidden="true"></svg></button>`);
  const results = await axe.run(document.body, {
    runOnly: { type: 'tag', values: ['ds-internal'] },
  });
  const ids = results.violations.map((v) => v.id);
  expect(ids).toContain('ds-icon-button-name');
});

it('passes an icon button with aria-label', async () => {
  mount(`<button data-icon-button aria-label="Close"><svg aria-hidden="true"></svg></button>`);
  const results = await axe.run(document.body, {
    runOnly: { type: 'tag', values: ['ds-internal'] },
  });
  expect(results.violations.map((v) => v.id)).not.toContain('ds-icon-button-name');
});

it('does not match plain buttons (selector scoping)', async () => {
  mount(`<button>Save</button>`); // no data-icon-button → rule inapplicable
  const results = await axe.run(document.body, {
    runOnly: { type: 'tag', values: ['ds-internal'] },
  });
  expect(results.violations.map((v) => v.id)).not.toContain('ds-icon-button-name');
});

Test all three outcomes: a fail, a pass, and an inapplicable case proving your selector/matches scoping doesn't leak onto unrelated nodes. If your check can return undefined, add a fourth test asserting it lands in incomplete.

For verifying the real components rendered by your framework, run the same registered rule inside component testing with jest-axe so it gates actual JSX output, not just hand-written HTML fixtures.


How to Verify

  • Automated: run the rule against fixtures and against a real rendered component; confirm it appears in violations for bad input, passes for good input, and inapplicable when the selector shouldn't match.
  • Cross-check the engine version: custom config is tied to the loaded axe build. Log results.testEngine.version in CI and re-validate your rule when you upgrade axe-core—internal helpers like axe.commons are not a frozen public API.
  • Manual: for any rule about accessible names or roles, confirm with a screen reader that the thing your rule enforces actually produces the intended announcement. A rule that checks for aria-label presence still can't tell you the label is meaningful—verify that by ear.

When a Lint Rule or jest-axe Is Better

Custom axe rules are powerful but not always the right layer. Choose deliberately:

  • Reach for a lint rule (ESLint, eslint-plugin-jsx-a11y) when the defect is visible in source. Missing alt on an <img>, a static onClick on a <div>—catching these at author time, before render, is faster and needs no runtime. axe can't see source; lint can't see the rendered/hydrated DOM. They're complementary.
  • Reach for a plain jest-axe assertion when the requirement is one-off or component-local. If only your Modal needs aria-labelledby, a expect(container.querySelector('[role=dialog]')).toHaveAttribute('aria-labelledby') assertion in that component's test is simpler than a global rule and lives next to the component.
  • Reach for a custom axe rule when the requirement is cross-cutting and runtime-dependent. A convention that must hold across every page and only manifests in the live DOM (computed names, applied roles, conditionally rendered ARIA) is exactly what axe.configure() is for—register once, run everywhere, including E2E.

A useful heuristic: if it's a source pattern, lint it; if it's one component, assert it; if it's a system-wide runtime invariant, make it an axe rule.


Common a11y Mistakes

  1. Calling axe.configure() more than once. It replaces, not merges. Register all custom rules in a single setup module and axe.reset() between test suites if needed.
  2. Forgetting to scope the selector. A too-broad selector makes your rule fire on unrelated nodes and flood reports. Test the inapplicable case.
  3. Returning false when you mean "can't tell." If the check genuinely can't decide, return undefined so the node lands in incomplete—don't fabricate a pass or fail.
  4. Depending on axe.commons as a stable API. Internal helpers can change between versions; pin your axe-core version and re-test custom rules on upgrade.
  5. Building a custom rule for something lint already catches. Source-visible issues belong in eslint-plugin-jsx-a11y, not a runtime rule.

Conclusion

axe.configure() turns axe-core from a fixed checker into an extensible policy engine for your design system: a check holds the logic, a rule scopes and labels it, and both flow through the same violations/incomplete/passes model your team already reads. Author the rule, scope its selector tightly, test all three outcomes, and re-validate on every axe upgrade. Reserve custom rules for cross-cutting runtime invariants—let lint catch source patterns and jest-axe assertions handle the one-offs.


Frequently Asked Questions

What's the difference between a check and a rule in axe-core? A check is the evaluate function that inspects a single node and returns true, false, or undefined. A rule binds one or more checks to nodes via a CSS selector and carries metadata and tags. You register both with axe.configure(); the rule decides which nodes get tested, the check decides whether each passes.

Does axe.configure() merge with existing custom rules? No. axe.configure() replaces the active configuration each time it's called. Register all your custom checks and rules in one setup module, and call axe.reset() to return to defaults—important between test suites that reconfigure differently.

When should I write a custom axe rule instead of an ESLint rule? Use ESLint (eslint-plugin-jsx-a11y) when the issue is visible in source code, like a missing alt attribute—it's caught before render with no runtime cost. Use a custom axe rule when the requirement depends on the rendered, hydrated DOM (computed accessible names, applied roles, conditional ARIA) and must hold across the whole app.

Can a custom check return a "needs review" result? Yes. Return undefined from evaluate and axe classifies the node as incomplete rather than pass or fail. Use this when your logic genuinely can't decide automatically—the same mechanism the built-in color-contrast rule uses over images and gradients.