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 parent guide on the axe-core rules engine.

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.

How a custom axe rule flows a matched node to a result bucket A rule uses its CSS selector plus optional matches predicate to pick which DOM nodes are in scope. Each matched node is passed to the rule's check, whose evaluate function returns one of three values: true, false, or undefined. True routes the node to the passes bucket, false to violations, and undefined to incomplete — so the rule decides which nodes are tested and the check decides whether each one passes. rule = which nodes · check = whether each passes RULE selector + matches() picks the nodes matched node matched node matched node CHECK evaluate(node) decides each node passes returned true violations returned false incomplete returned undefined

The rule decides which nodes are in scope; the check decides whether each one passes. Keeping that split clear is what lets you reuse one strict check across several narrowly-scoped rules.


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><i class="icon-close" aria-hidden="true"></i></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"><i class="icon-close" aria-hidden="true"></i></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 testing React components with jest-axe so it gates actual JSX output, not just hand-written HTML fixtures. When a custom rule fires in CI but not locally, the techniques in debugging jest-axe violations in CI apply unchanged.


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.

Where a custom rule belongs, compared with the alternatives Three tools matched to three kinds of convention. A lint rule is right when the mistake is visible in source, such as a component missing a required prop. A custom axe rule is right when the mistake only exists in the rendered DOM across a whole page, such as a design-system class used without its required attribute. A component test is right when the rule depends on interaction or state that neither of the others can reach. Visible in source → a lint rule a component used without a required prop; feedback in the editor Only in the rendered DOM → a custom axe rule a design-system class present without the attribute it requires, page-wide Depends on interaction → a component test state after a click, focus after a close — neither static tool can reach it

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 the end-to-end tests you run in Playwright.

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.

Decision tree: where should an accessibility rule live? Ask three questions in order. If the defect is visible in source, catch it with an ESLint rule before render. Otherwise, if it applies to just one component, use a plain jest-axe assertion that lives beside that component. Otherwise, if it is a cross-cutting runtime invariant that must hold across every page, register a custom axe.configure() rule that runs everywhere. yes no yes no yes Is the defect visible in source? Applies to just one component? Cross-cutting runtime invariant? ESLint — jsx-a11y source pattern, before render jest-axe assertion one component, lives beside it custom axe.configure() rule register once, run everywhere

Once the rule earns its place, wire it into your CI/CD accessibility gate so a regression fails the pull request on an axe violation instead of merging unnoticed.


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.