Component-Specific Axe Rule Writing

A design-system component is a promise about markup: this wrapper will label its control, this wrapper will connect its hint text to the thing being hinted, this wrapper will announce its own error. Nothing in the browser enforces that promise, and no built-in accessibility rule knows the promise exists, so the day a consumer copies the markup and drops one attribute the component silently stops working for anyone using assistive technology. This guide is part of Custom Rule Development & Context-Aware Testing, and it covers how to turn one component’s contract into an executable axe rule: which half of the work belongs in a check, which half belongs in a rule, when the honest answer is incomplete rather than a failure, and how to write the failure message so the developer who reads it in a pull request knows exactly which attribute to add.

Problem Statement

The failure mode that motivates a component rule is not a missing alt attribute or a low-contrast button — the built-in catalogue already covers those. It is a component whose parts are individually valid and collectively meaningless. Take a form-field wrapper: <div data-component="field"> containing a <label>, an <input>, a hint paragraph and, in the error state, a message. Every built-in rule passes. The label has text, so form-field-multiple-labels is happy. The input has an accessible name, so label is happy. The hint paragraph is a <p> with text in it, so nothing objects. And yet if the hint’s id never appears in the input’s aria-describedby, a screen-reader user reaches the input, hears “Email, edit text”, and never learns that the field only accepts a work address. The information exists on screen and is absent from the accessibility tree — a WCAG 2.2 SC 1.3.1 (Info and Relationships) failure that no scanner reports because the relationship it is looking for was never declared.

That gap is what a component rule closes, and the reason it is worth the effort is arithmetic. A design-system field wrapper is used a few thousand times across a product portfolio. A specialist reviewing one instance costs an hour and protects one instance. A rule costs a day and protects every instance, on every pull request, in every repository that consumes the package, for as long as the component exists. The catch is that the rule has to be precise in two independent directions at once: precise about which elements it judges, and precise about what it judges them on. Getting the second right while getting the first wrong produces a rule that fails hand-rolled markup in a legacy page nobody on the component team can fix, and a rule that produces unfixable failures gets disabled within a week.

axe-core’s data model separates those two concerns deliberately, and most bad custom rules are bad because the author collapsed them into one function. A check is a pure assertion: it receives a node and answers a single question about it with true, false or undefined. A rule is an applicability declaration: it decides which nodes are worth asking, carries the WCAG tags and the impact, and names the checks it wants run. Keep the split clean and debugging becomes a two-way decision — wrong nodes means the bug is in the rule, wrong verdict means the bug is in the check. Blur the split, and every non-applicable element quietly becomes a pass, which is indistinguishable in the report from a rule that works.

Key implementation targets:

  • A check whose evaluate returns true, false or undefined, and which populates this.data() and this.relatedNodes() so the report points at coordinates rather than at the whole component.
  • A rule whose selector and matches function together guarantee the rule is inapplicable — not failing — on components the design system did not render.
  • A message template that interpolates the missing attribute and the target node, so the pull-request comment reads as an instruction instead of a rule id.
  • A registration module that runs axe.configure() inside the browser context, versioned with the component package it protects.
  • A suite that asserts all four possible outcomes of the rule: pass, violation, incomplete, and inapplicable.

Prerequisites

1. Identify the Contract to Encode

Start by writing the contract as a list of statements that are true of a correct instance and false of a broken one, using only facts a single settled DOM can prove. For the field wrapper, the contract is four statements: the wrapper contains exactly one form control; the control’s accessible name comes from the wrapper’s <label> through a for/id pair rather than from a placeholder; every non-empty hint or error node inside the wrapper has an id that appears in the control’s aria-describedby; and when the wrapper is in its error state the control carries aria-invalid="true".

Now test each statement against the only question that matters for rule authoring: can a DOM snapshot prove this false? The first three can. The fourth is more interesting, because the error state is a visual and temporal thing — a field mid-validation has a spinner, no message and no aria-invalid, and it is not broken, it is busy. That statement therefore needs a third outcome, which is the whole reason undefined exists.

Statements that fail this test do not belong in a rule at all. “The hint text explains the format clearly” is a human judgment. “Focus moves to the first invalid field on submit” is an interaction, so it belongs in a Playwright spec. “The label is not visually truncated” is a rendering fact a check cannot see. Route those elsewhere and keep the rule to the provable set; a rule that guesses at judgment produces failures that reviewers learn to dismiss, and a dismissed rule is worse than no rule because it also trains people to dismiss the ones that are right.

Assign one impact per statement while the contract is still on paper. A control with no programmatic label is critical — the field is unusable. A hint that is not referenced is serious — the field works but withholds information. A missing aria-invalid on an already-announced error is moderate. Those impacts drive the blocking threshold later, and deciding them before writing code stops the familiar drift where every custom rule ends up critical because its author cared about it most.

Three return values, three result buckets, three gate outcomes A check's evaluate function returns true, false or undefined. True lands in results.passes and leaves the gate green, false lands in results.violations and exits non-zero, and undefined lands in results.incomplete which is annotated for triage without failing the job. One assertion, three honest answers check.evaluate one question true results.passes false results.violations undefined results.incomplete no output gate stays green exit code 1 merge blocked exit code 0 queued for triage Returning false for something the DOM cannot answer converts an unknown into a blocked merge.
The third return value is not a fallback for errors; it is the verdict that keeps the violations list trustworthy when the DOM genuinely cannot answer the question.

2. Write the Check

A check is an object with an id, optional options, a metadata block holding the impact and the message templates, and an evaluate function. evaluate receives three arguments: the real DOM node, the resolved options, and the virtualNode — axe’s own wrapper around that node, which carries the flattened-tree relationships that matter once shadow roots are involved. The function must be a function expression, never an arrow, because axe calls it with this bound to a check context that supplies this.data() and this.relatedNodes().

The hint-reference check below encodes the third contract statement. It returns undefined in two situations, and both are worth reading closely: a wrapper whose control lives inside a nested custom element, because querySelector cannot see through a shadow root, and a wrapper that is mid-validation, because the attributes being asserted are being written as the check runs.

// a11y/rules/checks/ds-field-hint-referenced.js
// Contract statement 3: every hint or error node inside the field must be
// referenced from the control's aria-describedby.
export const dsFieldHintReferenced = {
  id: 'ds-field-hint-referenced',
  options: {
    // Overridable per consumer so a fork of the design system can rename slots.
    describerSelectors: ['[data-slot="hint"]', '[data-slot="error"]'],
  },
  metadata: {
    impact: 'serious',
    messages: {
      pass: 'Every hint and error node is referenced by the control it describes',
      // The failure message is the instruction, not a restatement of the rule.
      fail: 'Add ${data.missingIds} to aria-describedby on ${data.control}',
      incomplete: 'Field is ${data.reason}, so the reference cannot be proved yet',
    },
  },
  // A function expression: axe binds `this` to the check context.
  evaluate: function (node, options, virtualNode) {
    const control = node.querySelector('input, select, textarea');
    if (!control) {
      // A control inside a nested shadow root is invisible to querySelector.
      const shadowHost = Array.from(node.querySelectorAll('*'))
        .find((el) => el.shadowRoot);
      this.data({ reason: shadowHost ? 'wrapping a shadow-root control' : 'empty' });
      return shadowHost ? undefined : false;
    }
    if (node.getAttribute('aria-busy') === 'true') {
      // An async validator is writing aria-invalid and the error node right now.
      this.data({ reason: 'still validating (aria-busy="true")' });
      return undefined;
    }
    const referenced = (control.getAttribute('aria-describedby') || '')
      .trim().split(/\s+/).filter(Boolean);
    const describers = options.describerSelectors
      .flatMap((sel) => Array.from(node.querySelectorAll(sel)))
      // An empty hint node describes nothing and is not a failure.
      .filter((el) => el.textContent.trim() !== '');
    const missing = describers.filter(
      (el) => !el.id || !referenced.includes(el.id),
    );
    this.data({
      missingIds: missing.map((el) => el.id || 'an id on the hint node').join(', '),
      control: control.id ? `<${control.localName} id="${control.id}">` : `<${control.localName}>`,
      // virtualNode.actualNode === node; kept in the signature because the
      // shadow-aware sibling check reads virtualNode.children instead.
      flattenedChildren: virtualNode.children.length,
    });
    this.relatedNodes(missing);
    return missing.length === 0;
  },
};

Three details in that function are load-bearing. this.relatedNodes(missing) is what turns a report entry from “this field is wrong” into a list of the exact nodes that need ids, and axe renders those as separate targets in the JSON output, so a reviewer can click straight to them. this.data() supplies the interpolation values for the message templates — anything in the object is available as ${data.key}, which is how a single template serves every instance. And the empty-hint filter is the kind of guard that decides whether a rule is adopted: a design system that always renders the hint container, empty or not, would otherwise produce a failure on every field on the page.

The second check enforces the naming statement and shows the other reason to reach for undefined. It defers the accessible-name computation to axe.commons.text.accessibleText, which implements the full algorithm including aria-labelledby chains, title fallbacks and <label> association. Reimplementing that is the single most common source of custom-rule false positives.

// a11y/rules/checks/ds-field-control-labelled.js
// Contract statement 2: the name must come from the field's own <label>,
// not from a placeholder or a title attribute.
export const dsFieldControlLabelled = {
  id: 'ds-field-control-labelled',
  metadata: {
    impact: 'critical',
    messages: {
      pass: 'The control takes its accessible name from the field label',
      fail: 'Give the control an id and point the field label at it with for="${data.controlId}"',
      incomplete: 'No control found inside the field; verify the composition manually',
    },
  },
  evaluate: function (node) {
    const control = node.querySelector('input, select, textarea');
    if (!control) return undefined;
    const label = node.querySelector('label');
    // Defer to axe's own algorithm rather than reading attributes by hand.
    const name = axe.commons.text.accessibleText(control).trim();
    this.data({ controlId: control.id || 'email-input', resolvedName: name });
    this.relatedNodes(label ? [label] : []);
    if (!name) return false;
    // A name is not enough: it has to come from the label element.
    return Boolean(label) && label.htmlFor === control.id;
  },
};

Note the shape of the two fail messages. Neither mentions the rule, the check id, or the words “violation” or “must”. Both name the artifact that is missing and the attribute that would fix it, in the imperative, with the real value interpolated. That is the difference between a comment a developer acts on and a comment a developer searches the internet for. When the fix depends on which of several mechanisms the team uses, put the cheapest one in the message and leave the alternatives to the rule’s help text.

Anatomy of a failure message that names the fix The upper row splits a good failure message into three coloured segments: the fact that is broken, the node it is broken on, and the action that repairs it. The lower row shows a message that names only the check id and is annotated as missing all three. Every failure message carries three payloads "hint-email" is not in aria-describedby on the input with id="email" add hint-email to that attribute the broken fact the exact node the action to take "ds-field-describedby check failed on 4 nodes" names the rule instead: no fact, no node, no action Interpolate all three from this.data() so one template serves every instance.
A message that interpolates the missing id and the target control turns a pull-request annotation into a one-line patch instruction.

3. Write the Rule and Its Selector

The rule is where applicability lives. selector is a plain CSS selector evaluated against the flattened tree; it is the cheap first pass and should be as narrow as the design system’s own markup allows. matches is an optional function that receives the node and its virtualNode and returns a boolean; it is the expensive second pass, for anything CSS cannot express. Nodes that survive both are evaluated. Nodes that fail either are inapplicable, and if no node survives, the rule itself appears in results.inapplicable — a fact worth asserting on, because it is the only way to distinguish “the rule passed” from “the rule never ran”.

// a11y/rules/ds-field-rule.js
export const dsFieldRule = {
  id: 'ds-field-describedby',
  // Narrow first pass: only the design system stamps data-component.
  selector: '[data-component="field"]',
  matches: function (node, virtualNode) {
    // Only fields this version of the package rendered. Hand-rolled markup
    // copied from the docs has no version stamp and is not our contract.
    if (!node.hasAttribute('data-ds-version')) return false;
    // A decorative shell used for layout carries no accessibility contract.
    if (node.getAttribute('role') === 'presentation') return false;
    // A field the author has explicitly opted out of, with a reason recorded.
    if (node.hasAttribute('data-a11y-exempt')) return false;
    // Nothing to assert about a subtree removed from the accessibility tree.
    return !virtualNode.hasClass('ds-field--unmounted');
  },
  tags: ['wcag2a', 'wcag131', 'wcag332', 'cat.forms', 'ds-field', 'custom'],
  metadata: {
    description: 'Field hints and errors must be referenced by the control they describe',
    help: 'Add each hint and error node id to the control aria-describedby, or set '
      + 'aria-label on the control if the hint is genuinely decorative',
  },
  // all: every listed check must return true for the node to pass.
  all: ['ds-field-hint-referenced', 'ds-field-control-labelled'],
  // any: at least one must return true. Empty here: there is no alternative
  // mechanism that satisfies this contract.
  any: [],
  // none: no listed check may return true. Used for anti-patterns.
  none: ['ds-field-placeholder-as-label'],
};

The three arrays are the part of the rule format people most often get wrong. all is a conjunction — every check must pass. any is a disjunction, and it is the right home for a contract satisfiable several ways: a component that may be named by a visible label or by aria-label or by a title on a wrapper puts three checks in any and passes if one returns true. none inverts the polarity: the checks listed there describe things that must not be true, so a check named ds-field-placeholder-as-label returns true when it finds the anti-pattern, and the rule fails on that. Mixing the polarities up produces a rule that appears to work on the fixture that was used while writing it and inverts on every other input.

The matches function deserves the same scrutiny as the check. Every early return false in it is a deliberate statement about ownership, and each one should be traceable to a real class of markup the team encountered rather than added defensively. The version stamp is the important one: without it, the rule fires on every element that looks like a field, including the copy-pasted example in a legacy template and the third-party payment widget that happens to use the same attribute name. With it, the rule fires only on markup the design system actually produced, which means every failure it reports has a fix that lives in code the reporting team can change.

Resist putting applicability logic in evaluate “just for now”. A check that opens with if (!isOurComponent(node)) return true; reports a pass for every element on the page, which inflates the passes count, hides the fact that the rule matched nothing meaningful, and makes the inapplicable assertion in the suite impossible to write. The same logic in matches produces an honest empty result set.

Two filters run before the check ever executes Four stacked bars decreasing in width: 1,240 elements in the scan context, 38 matched by the CSS selector, 31 confirmed as owned by the matches function, and 31 nodes on which evaluate runs. Side notes record 1,202 elements never queried and 7 elements marked inapplicable. Applicability is decided twice, before evaluate runs once Elements in the scan context 1,240 selector: [data-component="field"] 38 1,202 skipped matches() confirms ownership 31 7 inapplicable evaluate() runs 31 31 verdicts Seven fields are inapplicable, not passing: no version stamp, so no contract to enforce.
The seven fields filtered out by matches never reach the check, so they inflate neither the passes count nor the violations list.

4. Register the Spec

Registration is one call to axe.configure(), and its only hard requirement is that it runs in the same JavaScript realm as the axe instance that will execute the scan, before axe.run() is invoked. Configuring from Node while the scan happens in a browser page is the most common way a correct rule never executes: the call succeeds against a different axe object and the page’s instance never hears about it.

Export the registration as a function that takes the axe instance rather than reaching for a global. That single change makes the module usable from a browser bundle, a Vitest browser-mode test and a jsdom unit test without a code path per environment.

// a11y/rules/ds-field.js
import { dsFieldHintReferenced } from './checks/ds-field-hint-referenced.js';
import { dsFieldControlLabelled } from './checks/ds-field-control-labelled.js';
import { dsFieldPlaceholderAsLabel } from './checks/ds-field-placeholder-as-label.js';
import { dsFieldRule } from './ds-field-rule.js';

// Takes the axe instance so the same module works in a page, in Vitest
// browser mode, and in a jsdom unit test.
export function registerDsFieldRules(axe) {
  axe.configure({
    // Surfaces in results.testEngine, so a report is traceable to a version.
    branding: { application: 'ds-field-rules@2.4.0' },
    checks: [dsFieldHintReferenced, dsFieldControlLabelled, dsFieldPlaceholderAsLabel],
    rules: [dsFieldRule],
  });
  return dsFieldRule.id;
}

For a browser run, bundle that module to an IIFE and load it after axe-core. The bundle must not assume a module loader, because it is injected as a raw script string into a page that has none:

npx esbuild a11y/rules/ds-field.js \
  --bundle \
  --format=iife \
  --global-name=dsFieldRules \
  --target=chrome120 \
  --outfile=a11y/rules/dist/ds-field-rules.js
# Then, inside the page: dsFieldRules.registerDsFieldRules(window.axe)

Two registration details bite later. First, axe.configure() replaces the entire rule and check entry for any id it declares, so a check id that collides with a built-in one silently overrides axe’s own implementation — prefix every id with the package name and never with a generic word like field or label. Second, a rule registered with enabled: false still appears in the catalogue and can be turned on per consumer with run options, which is the mechanism to use when adding a rule to a shared package: ship it disabled, let repositories opt in, and flip the default only in a major version.

5. Assert the Rule in the Suite

A custom rule is production code that decides whether other people’s pull requests merge, so it needs tests that prove all four outcomes. Unit tests against the evaluate function in jsdom are the fastest layer and are covered in detail in unit-testing custom axe rules with Jest fixtures; the layer below runs the whole rule — selector, matches, and every check in the arrays — through a real axe.run(), which is the only way to catch a matches function that never returns true or a check id that was renamed in one place.

// tests/a11y/ds-field-rule.browser.test.ts  (Vitest browser mode, Chromium)
import { describe, it, expect, beforeAll } from 'vitest';
import axe from 'axe-core';
import { registerDsFieldRules } from '../../a11y/rules/ds-field.js';

let ruleId: string;
beforeAll(() => {
  ruleId = registerDsFieldRules(axe);
});

// Runs the whole rule, not just the check, so matches() is exercised too.
async function run(html: string) {
  document.body.innerHTML = html;
  return axe.run(document.body, { runOnly: { type: 'rule', values: [ruleId] } });
}

const FIELD = (inner: string, attrs = 'data-ds-version="2.4.0"') =>
  `<div data-component="field" ${attrs}>${inner}</div>`;

describe('ds-field-describedby', () => {
  it('passes when every hint id is referenced', async () => {
    const r = await run(FIELD(`
      <label for="email">Work email</label>
      <input id="email" aria-describedby="hint-email">
      <p data-slot="hint" id="hint-email">Use your company address.</p>`));
    expect(r.violations).toEqual([]);
    expect(r.passes.map((p) => p.id)).toContain(ruleId);
  });

  it('fails and names the missing id when the hint is unreferenced', async () => {
    const r = await run(FIELD(`
      <label for="email">Work email</label>
      <input id="email">
      <p data-slot="hint" id="hint-email">Use your company address.</p>`));
    expect(r.violations).toHaveLength(1);
    // The message must contain the fix, not the rule id.
    expect(r.violations[0].nodes[0].all[0].message)
      .toContain('Add hint-email to aria-describedby');
  });

  it('is incomplete while an async validator is running', async () => {
    const r = await run(FIELD(`
      <label for="email">Work email</label>
      <input id="email">
      <p data-slot="error" id="err-email">Checking availability…</p>`,
      'data-ds-version="2.4.0" aria-busy="true"'));
    expect(r.violations).toEqual([]);
    expect(r.incomplete.map((i) => i.id)).toContain(ruleId);
  });

  it('is inapplicable to markup the design system did not render', async () => {
    // No data-ds-version: hand-rolled markup, not our contract.
    const r = await run(FIELD('<input id="email">', ''));
    expect(r.inapplicable.map((i) => i.id)).toContain(ruleId);
  });
});

That fourth test is the one people skip and the one that catches the most damaging class of bug. A matches function with a typo in the attribute name makes the rule inapplicable to everything, which produces a completely green report and a rule that has never once executed in production. Asserting inapplicability on a fixture that should be excluded, and asserting a pass on a fixture that should be included, pins the boundary from both sides.

For end-to-end coverage against the real application, the same registration module runs inside a Playwright scan by injecting the bundled file after axe.source; wire the resulting exit code into the gate as described in the CI/CD integration and quality-gating section. Keep the browser-mode rule tests in the package that owns the component, and keep the application scan in the application repository — the rule’s correctness and the application’s compliance are separate questions with separate owners.

What happens between axe.run and a violation entry Four vertical lifelines labelled axe.run, rule, check and results. Messages pass left to right as the flattened tree is walked, an applicable node reaches the check, the check writes data and related nodes, returns false to the rule, the rule appends a node entry to the violations array, and the assembled result object returns to the caller. axe.run() rule check results walk the flattened tree node survived matches() this.data({ missingIds }) this.relatedNodes([hint]) return false violations[0].nodes.push resolved result object The check never touches the result object directly; data and relatedNodes are its only outputs.
Because the check communicates only through its return value, this.data() and this.relatedNodes(), a rule can be re-pointed at a different selector without touching the assertion.

Pipeline Integration

The gate has to treat the three result buckets differently or the rule loses its value. Violations at serious and above set the exit code. Violations below that are reported and counted but do not block. Incomplete results become annotations, never failures, because their whole meaning is “a human needs to look at this”. The script below reads a Vitest or Playwright JSON report, emits GitHub workflow commands for each bucket, and sets the exit code from the blocking subset only.

// a11y/scripts/gate-ds-field.mjs — usage: node gate-ds-field.mjs ds-field-results.json
import { readFileSync } from 'node:fs';

const results = JSON.parse(readFileSync(process.argv[2], 'utf8'));
const BLOCKING = new Set(['critical', 'serious']);

let blockingNodes = 0;
for (const violation of results.violations) {
  for (const node of violation.nodes) {
    const level = BLOCKING.has(violation.impact) ? 'error' : 'warning';
    if (level === 'error') blockingNodes += 1;
    // One annotation per node, carrying the interpolated fix instruction.
    const message = node.all.map((c) => c.message).join(' | ');
    console.log(`::${level} title=${violation.id}::${node.target.join(' ')}${message}`);
  }
}
for (const pending of results.incomplete) {
  // Never an error: the DOM could not answer, which is not the author's fault.
  console.log(`::notice title=${pending.id} needs review::${pending.nodes.length} node(s)`);
}
console.log(`\n${blockingNodes} blocking node(s), ${results.incomplete.length} to review.`);
process.exitCode = blockingNodes > 0 ? 1 : 0;

Wire that into a job that runs the rule’s own tests before it trusts the rule against the application. If the rule is broken, the six-second unit run should say so, rather than a four-minute browser scan producing a violation list nobody can interpret.

name: ds-field-contract
on:
  pull_request:
    paths:
      - 'packages/ds-field/**'
      - 'a11y/rules/**'
      - '.github/workflows/ds-field-contract.yml'
jobs:
  contract:
    runs-on: ubuntu-24.04
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: npm
      - run: npm ci
      - name: Prove the rule before trusting it
        run: npx vitest run tests/a11y --reporter=dot # rule tests, not app tests
      - name: Scan the component stories
        run: npx vitest run --browser.headless --outputFile=ds-field-results.json
      - name: Annotate and gate
        if: always()
        run: node a11y/scripts/gate-ds-field.mjs ds-field-results.json
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: ds-field-contract-results
          path: ds-field-results.json
          retention-days: 21

Give any new component rule a soak period before it can fail a build. Run it for two weeks with the blocking set empty, watch which violations arrive, and fix the rule until the list contains only genuine defects — the ratcheting approach described in progressive threshold management applies to a new rule exactly as it applies to a violation budget. A rule that blocks main on its first day gets disabled by the third pull request it touches.

Troubleshooting and Flaky-Test Mitigation

The rule reports nothing at all, in every bucket. The rule is inapplicable, not passing. Run the scan with runOnly set to just this rule and inspect results.inapplicable; if the id is there, the selector or matches excluded every node. The usual culprits are an attribute typo, a selector written against the component’s internal markup rather than its root, and a matches function that reads an attribute the framework only sets after hydration.

TypeError: this.data is not a function in the browser console. evaluate was written as an arrow function, so this is the module scope rather than the check context. Convert it to a function expression. This one only reproduces at scan time, in the page, which is why it costs an afternoon when the unit tests call evaluate.call(ctx, node) and therefore never notice.

The rule passes locally and fails in CI on the same commit. Almost always a text-content comparison. textContent.trim() on a node whose copy is loaded from a translation bundle returns an empty string until the bundle resolves, so the empty-hint guard flips. Wait for the component’s own settled signal before scanning rather than for network idle, and assert on the presence of the id relationship rather than on the text.

Incomplete results appear intermittently on the same story. That is the aria-busy branch doing its job during an async validation round trip. Either wait for aria-busy to clear before the scan, or accept the incomplete and assert only that no violation was produced. Never “fix” the flake by removing the undefined branch — that converts a race condition into a false failure, which is strictly worse.

A violation appears on a component the team does not own. The matches function is too permissive. Add the ownership condition rather than adding an exclude selector in the run options: an exclusion lives in one repository’s configuration and has to be re-added in every other consumer, whereas a tightened matches fixes the rule for everybody at once.

Every node fails after an axe-core upgrade. Check for a check id collision. axe.configure() overwrites any id it declares, and if a new axe release introduced a built-in check with the same id, the custom implementation is now standing in for it — or, worse, the built-in is standing in for the custom one. Namespace every id with the package prefix and pin the axe-core minor version in the rule package’s peer dependency range.

Common Pitfalls

  • Putting the “is this my component” test inside evaluate and returning true, which records a pass for every element on the page and makes an inapplicability assertion impossible to write.
  • Writing evaluate as an arrow function, which loses the this binding and throws only at scan time, in the browser, where the stack trace is least informative.
  • Hand-rolling accessible-name resolution instead of calling axe.commons.text.accessibleText, and thereby missing aria-labelledby chains, <label> association and title fallbacks.
  • Returning false for a state the DOM cannot answer — a lazily mounted panel, a control behind a shadow root, a field mid-validation — instead of undefined.
  • Writing a failure message that restates the rule id rather than interpolating the missing attribute and the node that needs it.
  • Using a generic check id such as label-present, which can silently override a built-in axe check of the same name after an upgrade.
  • Scoping the rule to a structural selector like div[role] instead of to a marker the design system stamps, so failures land on markup the reporting team cannot change.
  • Registering the rule from Node while the scan runs in a browser page, so axe.configure() succeeds against an axe instance that never executes.
  • Confusing the all, any and none arrays, which produces a rule that behaves correctly on the one fixture used while writing it and inverts on the rest.

FAQ

Should one rule contain several checks, or should each check get its own rule? Group checks into one rule when they describe the same contract and a developer would fix them in the same commit, and split them when the impacts differ enough to need different gate treatment. The field example groups naming and description into one rule because both are fixed in the same markup edit, but a moderate assertion about aria-invalid belongs in a second rule so it can report as a warning while the first blocks. Remember that the rule, not the check, carries the tags and therefore the WCAG mapping.

How is impact decided when it appears on both the check and the rule? The impact that reaches the report comes from the failing check’s metadata; the value on the rule acts as the default for checks that do not declare one. In practice, set it on the check, because that is where the severity of the specific assertion lives — one rule can legitimately contain a critical naming check and a serious description check, and flattening both to the rule’s value destroys the distinction the gate depends on.

Does a custom rule slow the scan down noticeably? Rarely, and when it does the cause is the selector, not the evaluate. axe evaluates the selector against the whole flattened tree, so a broad selector such as div or [role] means the engine collects thousands of candidate nodes before matches throws almost all of them away. A narrow attribute selector keeps the candidate set in the tens, and at that size the cost of the check body is irrelevant even when it queries the subtree several times.

What happens if a check throws instead of returning? axe catches the exception and the whole run fails with a rule-level error rather than producing a violation list, so the job fails for a reason that looks nothing like an accessibility problem. Guard every attribute read against null, never assume a queried element exists, and keep the rule’s unit tests fast enough that they run before the browser scan — a thrown TypeError on a fixture costs seconds, and the same error inside a scan costs a debugging session.

Can the same rule be used for other component families, like tables or custom elements? The check usually cannot be reused, but the structure always is. Complex tables need a grid-walking algorithm rather than an attribute comparison, which is worked through in writing custom axe-core rules for complex data tables. Components that encapsulate their markup need the flattened-tree API instead of querySelector, covered in writing axe rules for web components and shadow DOM. Composite widgets with state need a scan that opens the widget first, which is the subject of writing a custom axe rule for a combobox pattern.

In This Section