Custom Rule Development & Context-Aware Testing

A built-in accessibility rule can only assert what one DOM snapshot proves about one element in isolation, which is why a combobox with a stale aria-controls target, a live region that gets destroyed on every route change, or an ARIA label left in English inside an Arabic build all pass a default scan. This section covers how to author, register, unit-test and distribute custom rules that know a component’s contract, the timing of a client-side render, and the locale the page is running in — and how to run that rule set as a blocking CI job without flooding pull requests with noise.

Key implementation targets:

  • A custom check (the assertion) paired with a custom rule (the selector, matches filter and tags) that axe evaluates alongside its built-in catalogue.
  • A rule bundle that registers through axe.configure() inside the browser context and is injected into a Playwright run without forking axe-core.
  • Scan timing that fires after hydration, route transition and virtualised-list rendering have settled, so late DOM is in scope.
  • Locale-aware checks that fail a build when a translated string, lang value or direction does not match the locale under test.
  • Unit tests for every check on fixed HTML fixtures, so a rule change is proven against both a passing and a failing DOM before it reaches a shared pipeline.
  • A versioned, published bundle that dozens of repositories consume without each team copying evaluate functions between projects.
Custom rule lifecycle from authoring to CI gate Six numbered stages run left to right then right to left: author the check and rule, unit-test on fixtures, publish a versioned bundle, register it with axe.configure inside the browser run, scan once the route has settled, and gate on impact, ending in either a blocked pull request or an allowed merge. Authoring in the repository 1 Author check + rule matches / evaluate / tags 2 Unit-test on fixtures jsdom + Jest, both cases 3 Publish the bundle semver + registry Run time in the browser, then the gate 4 axe.configure(spec) injected with axe.source 5 Scan when settled route + hydration idle 6 Gate on impact serious+ blocks merge exit 1 PR blocked exit 0 merge allowed
The same bundle that is authored and unit-tested in step one is the artifact injected at step four, so a rule behaves identically on a developer laptop and in the gate.

Before writing any custom code, be honest about which mechanism the failure actually needs. Not every accessibility bug belongs in axe: some are better caught by a test that drives the widget, some by a lint rule that never lets the markup exist, and some by an accessibility-tree snapshot. The table below is the trade-off matrix that decides where a new check goes.

Tool / approach WCAG coverage CI integration effort False-positive risk Custom-rule support
Built-in axe-core rule Broad AA structural coverage, ~30–40% of criteria Low — one analyze() call Low; tuned against real sites None beyond enable/disable and options
Custom check + rule pair Only what you assert, mapped to chosen SCs Medium — bundle must be built and injected Medium; your selector, your bugs Full: matches, evaluate, tags, impact
Playwright / Cypress assertion Interaction and timing criteria axe cannot see Medium — lives in the existing suite Low per assertion, but flaky under bad waits Not a rule; no aggregated reporting
ESLint jsx-a11y rule Static markup criteria only, pre-render Low — runs in the lint job High on dynamic props and spreads Full, but AST-level not DOM-level
Accessibility-tree snapshot Name, role and state regressions Low — snapshot per component Medium; churns on harmless copy edits Indirect: the snapshot is the contract

Core Principles

Shift-left validation is the whole point of a custom rule. A component-contract violation found by an accessibility auditor six weeks after release costs a design review, a ticket, a sprint and a regression test; the same violation caught by a rule that runs on every pull request costs one commit. That economics only works if the rule runs in the same place developers already get feedback, so a custom rule set belongs in the pull-request job next to unit tests, not in a nightly audit whose output nobody reads. Register the bundle in the local dev server too — a rule that only exists in CI teaches developers that accessibility is a gate rather than a tool.

Severity thresholds keep the gate credible. axe assigns each result an impact of minor, moderate, serious or critical, and custom checks declare their own impact in check metadata. Set the blocking threshold at serious and above for custom rules, report moderate and minor as annotations, and give every new rule a soak period in warning mode before it can fail a build — the same ratchet used for budgets in progressive threshold management. A new rule that blocks main on its first day will be disabled by the third pull request that hits it, and a disabled rule protects nobody.

The automated-versus-manual split is the reason custom rules exist at all. Automated scanners reliably detect roughly 30–40% of WCAG failures; the remainder — meaningful alt text, sensible focus order for a novel widget, whether an error message actually explains the error — requires human judgment. Custom rules do not raise that ceiling by inventing judgment. They raise it by encoding decisions a human already made: once a specialist has ruled that this combobox must expose aria-expanded and own its listbox, that decision becomes a machine-checkable invariant, and it never needs to be re-litigated in a review. Everything that still needs a human should be routed to manual testing rather than approximated by a rule that guesses.

Prefer incomplete over a guess. Returning undefined from a check’s evaluate puts the node in the incomplete array instead of violations, which is exactly right when the DOM does not contain enough information — a lazily rendered popup, a collapsed disclosure, an element inside a closed shadow root. Incomplete results can be surfaced as PR comments for triage without failing the job, which keeps the violation list trustworthy. A check that returns false when it simply cannot tell is the fastest way to lose the team’s confidence in the whole rule set.

Choosing between a custom rule, a test assertion and a lint rule Three questions asked in order: does the failure need interaction or timing, is it visible in the JSX source, and does it repeat across pages or teams. Each yes routes to a mechanism on the right, and the final no falls through to an accessibility-tree snapshot. Start: one failure class to prevent Mechanism to use Needs interaction or timing? Visible in the JSX source? Repeats across pages or teams? Playwright / Cypress assertion it drives the widget ESLint jsx-a11y rule caught before commit Custom axe check + rule runs on every scanned page Accessibility-tree snapshot locks the shape you fixed yes yes yes no no no
Work the questions in order: interaction and timing failures belong in a spec, source-visible failures belong in lint, and only repeating DOM-provable failures earn a custom rule.

Component-Specific Rule Writing

A design-system component has a contract that no generic rule knows about: this table’s data cells must resolve to a header even when the header row spans two levels, this combobox must own its listbox and expose aria-expanded, this custom element must forward its label across the shadow boundary. Component-specific rule writing is where those contracts become executable, and it is the highest-value place to start because a single rule protects every instance of the component across every product that consumes it.

The mechanics are a check that answers one yes/no question and a rule that decides which nodes get asked. Keep the check narrow — one assertion, one impact, one message per outcome — and push all the “does this element even qualify” logic into the rule’s selector and matches function. That separation is what makes a rule cheap to debug: if the wrong nodes are being tested, the bug is in matches; if the right nodes get the wrong verdict, the bug is in evaluate.

Anatomy of an axe check versus an axe rule The left card lists a check's fields: id, evaluate signature, options, impact metadata and the three possible return values. The right card lists a rule's fields: id, selector, matches, tags and the all array that references the check by id. check — the assertion rule — where it runs id: 'table-headers-resolve' evaluate(node, options, vNode) options: { requireIds: true } metadata.impact: 'serious' true / false / undefined id: 'complex-table-headers' selector: 'table' matches: complex tables only tags: wcag2a, wcag131, custom all: ['table-headers-resolve'] by id The check answers the question; the rule decides which nodes are asked.
Checks are reusable assertions referenced by id from a rule's all, any or none array, so one check can back several rules with different selectors and tags.

The multi-level data table is the canonical example. axe’s built-in td-headers-attr and th-has-data-cells rules catch the obvious breakages, but neither asserts that every data cell in a table with a two-row header actually resolves to a header, which is the failure blind users report as “the numbers have no meaning”. The check below uses axe’s own table commons to resolve headers the way a screen reader would, and reports the orphaned cells as related nodes so the CI output points at real coordinates instead of the whole table.

// a11y/rules/checks/table-headers.js
// Every data cell in a complex table must resolve to at least one header.
export const tableHeadersResolve = {
  id: 'table-headers-resolve',
  options: { requireIds: true }, // headers="" ids must exist inside this table
  metadata: {
    impact: 'serious',
    messages: {
      pass: 'All data cells resolve to a header',
      fail: 'Data cells resolve to no header: ${data.orphanCount} cell(s)',
    },
  },
  // A function, not an arrow: axe binds `this` to the check context so that
  // this.data() and this.relatedNodes() are available.
  evaluate: function (node, options) {
    const cells = Array.from(node.querySelectorAll('td'));
    const orphans = cells.filter((cell) => {
      const explicit = cell.getAttribute('headers');
      if (explicit) {
        const ids = explicit.trim().split(/\s+/);
        // A headers list pointing at a missing id is worse than no list.
        return options.requireIds
          ? !ids.every((id) => node.querySelector(`th#${CSS.escape(id)}`))
          : false;
      }
      // No headers attribute: fall back to scope and grid position, exactly
      // as axe resolves implicit headers for built-in table rules.
      return axe.commons.table.getHeaders(cell).length === 0;
    });
    this.data({ orphanCount: orphans.length });
    this.relatedNodes(orphans);
    return orphans.length === 0;
  },
};

Two variants of the same component family need their own treatment. Shadow DOM breaks document.querySelector, so a check that must cross a shadow boundary works from the virtualNode axe hands it rather than from the document — the pattern is worked through in writing axe rules for web components and shadow DOM. Composite widgets need state, not structure: a combobox is only checkable in its collapsed and expanded forms, which is why writing a custom axe rule for a combobox pattern pairs the rule with a spec that opens the popup before scanning and returns incomplete when the listbox has not been rendered yet.

Scope every component rule to a marker the design system controls — a custom element name, a data-component attribute, a stable class contract — never to a generic selector like div[role]. A component rule that fires on hand-rolled markup in a legacy page produces failures nobody on the component team can fix, and ownership confusion is the most common reason a shared rule set gets abandoned.

DOM Inspection for Dynamic Content

Most “axe missed it” reports are timing reports. The scan ran, the DOM it walked was real, and the element that would have failed had not been created yet. DOM inspection for dynamic content covers the two halves of the fix: waiting for the right signal before scanning, and writing checks that describe post-hydration state rather than server markup.

The signal matters more than the wait. networkidle is not a rendering signal — a framework can finish every request and still be three animation frames from painting a list, and an app with a polling websocket never reaches idle at all. Wait on something the application asserts about itself: a data-route-state="settled" attribute, a resolved promise exposed on window, or a period of DOM quiet measured with a MutationObserver. The quiet-period helper below is small enough to live in the test utilities and is the wait used by the reference pipeline further down this page.

// tests/a11y/wait-for-quiet-dom.js
// Resolve once the DOM has stopped mutating for `quietMs`, or bail at `timeoutMs`.
export async function waitForQuietDom(page, { quietMs = 400, timeoutMs = 8000 } = {}) {
  await page.waitForFunction(
    ([quiet, timeout]) =>
      new Promise((resolve) => {
        let timer = setTimeout(() => finish(true), quiet);
        const hardStop = setTimeout(() => finish(false), timeout);
        const observer = new MutationObserver(() => {
          clearTimeout(timer);            // any mutation restarts the quiet window
          timer = setTimeout(() => finish(true), quiet);
        });
        observer.observe(document.body, {
          childList: true, subtree: true, attributes: true,
          // aria-* churn counts as activity: state is still settling.
          attributeFilter: ['aria-expanded', 'aria-busy', 'aria-live', 'hidden'],
        });
        function finish(quietReached) {
          clearTimeout(timer);
          clearTimeout(hardStop);
          observer.disconnect();
          resolve(quietReached);
        }
      }),
    [quietMs, timeoutMs],
  );
}
Why an early scan misses late-rendered DOM A time axis from zero to 1200 milliseconds marks DOM ready at 100, hydration end at 350, virtualised list rows painted at 700 and the live region filled at 1000. A scan window at 250 milliseconds sits before three of those milestones, while a scan after the settle signal sits after all of them. Time after a client-side route change DOM ready 100 ms list rows paint 700 ms hydration ends 350 ms live region set 1000 ms 0 300 ms 600 ms 900 ms 1200 ms scan at 250 ms 3 rules never fire scan when settled all 5 rules evaluated
An early scan is not a false negative in the rule; it is a false negative in the wait, because three of the five custom rules had no nodes to match.

Checks themselves need to be written for live state. A framework sets aria-expanded, aria-selected and aria-busy after the fact, sometimes on a different node than the one that carries the role, and handling dynamic ARIA states in modern JavaScript frameworks shows how to resolve the attribute from the element that actually owns the role rather than the wrapper the framework happened to render.

Virtualised lists are the opposite problem: the DOM is deliberately incomplete, and a rule that only sees twelve rendered rows out of ten thousand will silently pass a table whose off-screen rows are broken. Scanning in scroll windows, or scanning the row component in isolation, is the tractable answer, covered in scanning virtualised lists without false negatives. Treat the window-scan result as a sample, and treat the row component’s own test as the real guarantee.

One rule of thumb keeps this section from becoming an exercise in adding sleeps: every wait must be conditional on an observable fact, and every unconditional wait must be justified in a comment. A page.waitForTimeout(2000) sprinkled before an analyze() call will hold the pipeline together for a month and then start failing on a slower runner, and the failure will be blamed on accessibility rather than on the wait.

Handling Single-Page Application Routing

In a server-rendered site, one URL means one document, so a scan per URL is a complete audit. In a single-page application, one document serves every URL and the interesting accessibility state is created and destroyed by the router. Handling single-page application routing deals with the consequences: what to scan, when a route counts as arrived, and which failures only exist on the second navigation.

The most damaging SPA-specific failure is the detached live region. A status container rendered inside the route outlet is destroyed and recreated on navigation, and a live region that is inserted into the DOM at the same time as its text has nothing to compare against, so assistive technology announces nothing. The fix is structural — the region lives in the persistent shell, outside the swapped subtree — which makes it a perfect custom rule, because the structure is provable from a single settled DOM.

// a11y/rules/checks/route.js
// Two structural invariants that only break in client-side routed apps.
export const liveRegionPersistsRoute = {
  id: 'live-region-persists-route',
  metadata: {
    impact: 'serious',
    messages: {
      pass: 'Live region lives outside the route outlet',
      fail: 'Live region is inside the route outlet and is replaced on navigation',
      incomplete: 'No [data-route-outlet] marker found; verify the shell manually',
    },
  },
  evaluate: function (node) {
    const outlet = document.querySelector('[data-route-outlet]');
    if (!outlet) return undefined;          // cannot prove either way: incomplete
    const insideOutlet = outlet.contains(node);
    this.data({ insideOutlet });
    return !insideOutlet;
  },
};

export const routeHeadingFocusable = {
  id: 'route-heading-focusable',
  metadata: {
    impact: 'moderate',
    messages: {
      pass: 'Route outlet exposes a programmatically focusable heading',
      fail: 'Route outlet has no focusable landing target for the new view',
    },
  },
  evaluate: function (node) {
    const heading = node.querySelector('h1, [role="heading"][aria-level="1"]');
    if (!heading) return false;
    this.relatedNodes([heading]);
    // tabindex="-1" makes the heading focusable by script but not by Tab,
    // which is the accepted landing-target pattern after a route change.
    return heading.getAttribute('tabindex') === '-1';
  },
};

Arrival timing is the second half. A router updates location before the view renders, so a scan triggered by a URL change tests the previous view’s DOM with the new view’s URL in the report — a genuinely confusing artifact to debug. Waiting for route transitions before an axe scan sets out the signals worth waiting on, and detecting detached ARIA live regions in SPA navigation pairs the structural rule above with a runtime assertion that a real announcement happened.

Always scan the second navigation, not just the first. Fresh loads hide an entire failure class: focus left on a stale element, a modal’s inert never cleared, duplicate landmark regions accumulating because the outlet appends instead of replacing, event listeners on a node that no longer exists. A route matrix that visits A → B → A catches teardown bugs that a per-URL crawl of the same app will never produce, and it costs one extra page.goto per spec.

Internationalization & Localization Testing

Locale switches change more of the accessibility tree than teams expect: the accessible name of every control, the document language, the direction, and the length of every string that has to fit inside a fixed control. A pipeline that scans only the default locale ships those regressions to the locales that get the least manual testing. Internationalization and localization testing makes the locale a scan dimension rather than an afterthought.

The check axe cannot supply on its own is “is this label actually translated”. axe verifies that a control has an accessible name; it has no dictionary, so an English aria-label passes in every locale. Supplying the source-locale strings as check options turns that into a machine-checkable assertion: if the document language is not the source language, and the accessible name is byte-identical to a known source-locale string, the translation pipeline dropped a key.

// a11y/rules/checks/aria-label-localized.js
// Fail when an ARIA label is still the source-locale string in a translated build.
export const ariaLabelLocalized = {
  id: 'aria-label-localized',
  options: {
    sourceLocale: 'en',
    // Populated at build time from the source-locale message catalogue.
    sourceStrings: ['Search', 'Close', 'Next page', 'Sort ascending'],
  },
  metadata: {
    impact: 'serious',
    messages: {
      pass: 'Accessible name is localized for the current document language',
      fail: 'Accessible name "${data.label}" is the ${data.sourceLocale} string',
    },
  },
  evaluate: function (node, options) {
    const docLang = (document.documentElement.lang || '').split('-')[0];
    if (!docLang || docLang === options.sourceLocale) return true;
    const label = axe.commons.text.accessibleText(node).trim();
    if (!label) return true;               // a missing name is another rule's job
    const leaked = options.sourceStrings.some(
      (s) => s.toLowerCase() === label.toLowerCase(),
    );
    this.data({ label, docLang, sourceLocale: options.sourceLocale });
    return !leaked;
  },
};

Direction is the other half, and it fails structurally rather than semantically: dir="rtl" applied to a wrapper <div> instead of <html>, physical CSS margins that do not mirror, embedded Latin text or digits that reorder without bidi isolation. Validating RTL ARIA attributes in automated tests covers the direction and lang assertions, while testing internationalized labels in automated a11y workflows covers translation coverage across the whole label surface.

Keep the locale matrix small and deliberate. Scanning twenty locales on every pull request multiplies runtime and produces twenty copies of the same violation, which buries the one locale-specific failure. Scan the source locale plus one long-string locale (German is the usual pick) plus one right-to-left locale on pull requests, and run the full locale matrix nightly with results routed to the reporting and violation-tracking dashboards rather than to a blocking gate.

Custom Rule Testing & Distribution

A custom rule is production code that runs on every pull request in every repository that consumes it, and it deserves the same treatment as any other shared library: unit tests, semantic versioning, a changelog and a release process. Custom rule testing and distribution is the topic that keeps a rule set from decaying into a folder of copy-pasted evaluate functions with subtly different behaviour in each repository.

Test each check against fixed HTML fixtures rather than against the live application. A fixture makes the failing DOM explicit, runs in milliseconds under jsdom, and — critically — proves both polarities: the check must fail the broken fixture and pass the fixed one. A check that only has a failing test will happily return false for everything, and a check that only has a passing test will happily return true for everything. Unit-testing custom axe rules with Jest fixtures works through the harness; the shape is this:

// a11y/rules/checks/table-headers.test.js
import { JSDOM } from 'jsdom';
import { tableHeadersResolve } from './table-headers.js';

// Minimal check context: axe binds these two helpers onto `this` at run time.
const ctx = { data: () => {}, relatedNodes: () => {} };

function tableFrom(html) {
  const dom = new JSDOM(`<!doctype html><body>${html}</body>`);
  global.CSS = { escape: (s) => s }; // jsdom omits CSS.escape in older versions
  return dom.window.document.querySelector('table');
}

test('fails when a headers attribute points at a missing id', () => {
  const table = tableFrom(`
    <table><tr><th id="q1">Q1</th></tr>
    <tr><td headers="q9">12</td></tr></table>`);
  const result = tableHeadersResolve.evaluate.call(
    ctx, table, tableHeadersResolve.options);
  expect(result).toBe(false);
});

test('passes when every headers id resolves inside the table', () => {
  const table = tableFrom(`
    <table><tr><th id="q1">Q1</th></tr>
    <tr><td headers="q1">12</td></tr></table>`);
  const result = tableHeadersResolve.evaluate.call(
    ctx, table, tableHeadersResolve.options);
  expect(result).toBe(true);
});

Distribution is a versioning problem. Adding a rule to a shared bundle turns green pipelines red in repositories that never asked for it, so treat any new rule or any widening of an existing selector as a breaking change: ship it disabled by default, let consumers opt in with a tag, and only flip the default in a major release. Versioning custom rules without breaking existing pipelines sets out the compatibility policy, and publishing a shared axe rule package to a private registry covers the release mechanics, including publishing the pre-bundled browser file so consumers never need a build step of their own.

Which topic catches which failure class Rows are the five topics in this section and columns are five failure classes: ARIA wiring errors, late-render misses, route and live-region bugs, locale and RTL faults, and rule drift in pipelines. A filled mark means the topic owns that failure class and a light mark means partial coverage. ARIA wiring errors Late-render misses Route + live region bugs Locale and RTL faults Rule drift in pipelines Component-specific rules Dynamic DOM inspection SPA routing + live regions i18n and localization Rule testing + distribution filled = owns the failure class, light = partial coverage, dash = not covered here
Every failure class has exactly one owning topic, which is how a violation report maps back to a single place to fix the rule.

Reference Pipeline

The four files below are a complete custom-rule pipeline: a bundle entry point that registers five checks and five rules, a Playwright project that builds and serves the app before injecting the bundle, a spec that scans a route matrix once the DOM has settled, and a GitHub Actions job that unit-tests the rules before it trusts them. The check modules referenced by the entry point are the ones authored earlier on this page, plus the combobox module from the component rule guide.

// a11y/rules/index.js
// Bundled to a11y/rules/dist/bundle.js and injected after axe-core:
//   npx esbuild a11y/rules/index.js --bundle --format=iife \
//     --target=chrome120 --outfile=a11y/rules/dist/bundle.js
import { comboboxControlsListbox } from './checks/combobox.js';
import { tableHeadersResolve } from './checks/table-headers.js';
import { liveRegionPersistsRoute, routeHeadingFocusable } from './checks/route.js';
import { ariaLabelLocalized } from './checks/aria-label-localized.js';

const axe = window.axe;
if (!axe) throw new Error('inject axe-core before the custom rule bundle');

axe.configure({
  // Shows up in results.testEngine so a report is traceable to a bundle version.
  branding: { application: 'acme-a11y-rules@3.2.0' },
  checks: [
    comboboxControlsListbox,
    tableHeadersResolve,
    liveRegionPersistsRoute,
    routeHeadingFocusable,
    ariaLabelLocalized,
  ],
  rules: [
    {
      id: 'combobox-aria-wiring',
      selector: '[role="combobox"]',
      tags: ['wcag2a', 'wcag412', 'custom'],
      metadata: {
        description: 'Combobox must expose expanded state and own a real popup',
        help: 'Set aria-expanded and point aria-controls at the listbox',
      },
      all: ['combobox-controls-listbox'],
    },
    {
      id: 'complex-table-headers',
      selector: 'table',
      // Narrow the rule to genuinely complex tables; simple tables are already
      // covered by axe's built-in th-has-data-cells rule.
      matches: function (node) {
        const headerRows = node.querySelectorAll('thead tr').length;
        const spans = node.querySelectorAll('th[colspan], th[rowspan]').length;
        return headerRows > 1 || spans > 0;
      },
      tags: ['wcag2a', 'wcag131', 'custom'],
      metadata: {
        description: 'Data cells in a complex table must resolve to a header',
        help: 'Add scope or headers so every cell has an announced header',
      },
      all: ['table-headers-resolve'],
    },
    {
      id: 'live-region-outside-outlet',
      selector: '[aria-live], [role="status"], [role="alert"]',
      tags: ['wcag21aa', 'wcag413', 'custom'],
      metadata: {
        description: 'Live regions must survive a client-side route change',
        help: 'Render the live region in the app shell, not the route outlet',
      },
      all: ['live-region-persists-route'],
    },
    {
      id: 'route-focus-target',
      selector: '[data-route-outlet]',
      tags: ['wcag2a', 'wcag243', 'custom'],
      metadata: {
        description: 'A routed view must expose a focusable landing target',
        help: 'Give the view heading tabindex="-1" and focus it on arrival',
      },
      all: ['route-heading-focusable'],
    },
    {
      id: 'localized-aria-label',
      selector: 'button[aria-label], a[aria-label], [role="button"][aria-label]',
      tags: ['wcag2a', 'wcag312', 'custom'],
      metadata: {
        description: 'ARIA labels must be translated in non-source locales',
        help: 'Move the label into the message catalogue and translate it',
      },
      all: ['aria-label-localized'],
    },
  ],
});

Playwright owns the build-and-serve lifecycle so the same command works locally and on a runner. The webServer block below builds the app once and waits for the port; reuseExistingServer keeps local runs fast without affecting CI.

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: 'tests/a11y',
  timeout: 60_000,
  retries: process.env.CI ? 1 : 0, // one retry absorbs runner-level flakiness
  reporter: process.env.CI
    ? [['list'], ['json', { outputFile: 'a11y-report.json' }]]
    : [['list']],
  use: { baseURL: 'http://127.0.0.1:4173', locale: 'en-GB' },
  webServer: {
    command: 'npm run build && npm run preview -- --port 4173 --strictPort',
    url: 'http://127.0.0.1:4173',
    reuseExistingServer: !process.env.CI,
    timeout: 180_000, // a cold production build is slower than a dev server
  },
});

The spec injects axe plus the bundle as a single source string, so axe.configure() has already run by the time analyze() walks the page. Splitting the run into a custom-rule scan and a standard scan keeps the two failure reports separately attributable.

// tests/a11y/custom-rules.spec.ts
import { readFileSync } from 'node:fs';
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
import { source as axeSource } from 'axe-core';
import { waitForQuietDom } from './wait-for-quiet-dom.js';

// axe-core first, then the bundle that calls axe.configure() on window.axe.
const injected = `${axeSource}\n${readFileSync('a11y/rules/dist/bundle.js', 'utf8')}`;

// Visiting /reports twice exercises the route-teardown path, not just first paint.
const ROUTES = ['/', '/reports/quarterly', '/settings/notifications', '/reports/quarterly'];

for (const [index, route] of ROUTES.entries()) {
  test(`custom rules on ${route} (visit ${index + 1})`, async ({ page }, testInfo) => {
    await page.goto(route);
    await page.getByRole('main').waitFor();
    await waitForQuietDom(page, { quietMs: 400 });

    const results = await new AxeBuilder({ page, axeSource: injected })
      .withTags(['custom']) // only the bundle's rules; built-ins run in their own spec
      .analyze();

    await testInfo.attach(`axe-${index}.json`, {
      body: JSON.stringify(results, null, 2),
      contentType: 'application/json',
    });

    // Incomplete results are triage material, never a build failure.
    const blocking = results.violations.filter(
      (v) => v.impact === 'critical' || v.impact === 'serious',
    );
    expect(blocking.map((v) => `${v.id} x${v.nodes.length}`)).toEqual([]);
  });
}

The workflow runs the rule unit tests first. If a check is broken, the pipeline should say so in six seconds rather than fail a browser scan in four minutes with a misleading violation list.

name: a11y-custom-rules
on:
  pull_request:
    paths:
      - 'src/**'
      - 'a11y/rules/**'
      - 'tests/a11y/**'
      - '.github/workflows/a11y-custom-rules.yml'
concurrency:
  group: a11y-custom-rules-${{ github.head_ref }}
  cancel-in-progress: true
jobs:
  custom-rule-scan:
    runs-on: ubuntu-24.04
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: npm
      - run: npm ci
      - name: Unit-test the rule bundle
        run: npx jest a11y/rules --ci # fixtures only, no browser needed
      - name: Build the injectable bundle
        run: npm run build:a11y-rules # wraps the esbuild command above
      - run: npx playwright install --with-deps chromium
      - name: Scan the route matrix with custom rules
        run: npx playwright test tests/a11y/custom-rules.spec.ts
      - name: Publish a rule-level summary
        if: always()
        run: node a11y/scripts/summarize.mjs a11y-report.json >> "$GITHUB_STEP_SUMMARY"
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: a11y-custom-rule-results
          path: |
            a11y-report.json
            test-results/
          retention-days: 14

The summary script turns the JSON reporter output into a table in the run summary, so a reviewer sees which rule fired without downloading an artifact.

// a11y/scripts/summarize.mjs — usage: node summarize.mjs a11y-report.json
import { readFileSync } from 'node:fs';

const report = JSON.parse(readFileSync(process.argv[2], 'utf8'));
const specs = report.suites.flatMap((s) => s.specs ?? []);
const failed = specs.filter((s) => !s.ok);

console.log('| Spec | Status |');
console.log('|---|---|');
for (const spec of specs) {
  console.log(`| ${spec.title} | ${spec.ok ? 'pass' : 'FAIL'} |`);
}
console.log(`\n**${failed.length} of ${specs.length} custom-rule specs failed.**`);
process.exitCode = 0; // reporting only: the test step already set the job status

WCAG 2.2 Coverage Mapping

Every custom rule declares the success criterion it enforces in its tags array, which is what makes a violation report readable by an auditor rather than only by the engineer who wrote the check. The mapping below covers the rules in this section; the built-in axe catalogue still supplies the broad AA baseline described in the accessibility testing fundamentals and tool selection section.

Success criterion Failure this section catches Custom check Rule ID
SC 1.3.1 Info and Relationships Data cell in a two-level header table resolves to no header table-headers-resolve complex-table-headers
SC 4.1.2 Name, Role, Value Combobox missing aria-expanded or pointing at a stale popup id combobox-controls-listbox combobox-aria-wiring
SC 4.1.2 Name, Role, Value ARIA state not updated on the node that owns the role assertion in the spec none — Playwright check
SC 4.1.3 Status Messages Live region recreated with its text, so nothing is announced live-region-persists-route live-region-outside-outlet
SC 2.4.3 Focus Order Route change leaves focus on the previous view’s link route-heading-focusable route-focus-target
SC 3.1.2 Language of Parts ARIA label still in the source locale in a translated build aria-label-localized localized-aria-label

Common Pitfalls

  • Writing the qualifying logic inside evaluate instead of matches, which makes every non-applicable node a pass and hides the fact that the rule never ran on anything.
  • Returning false when the DOM cannot answer the question — a collapsed popup, a closed shadow root, a virtualised row — instead of returning undefined and letting the node land in incomplete.
  • Using an arrow function for evaluate, which loses the this binding and makes this.data() and this.relatedNodes() throw at scan time, in the browser, where the stack trace is least useful.
  • Calling document.querySelector from a check that has to work inside shadow DOM, so the rule silently passes every custom element in the design system.
  • Registering the bundle with axe.configure() after analyze() has already been called, which is what happens when the configure call lives in Node rather than in the injected page source.
  • Shipping a new rule enabled by default, turning every consuming repository red on a patch upgrade and guaranteeing the whole bundle gets pinned to an old version.
  • Tagging custom rules with custom only and then running the scan withTags(['wcag2a']), so the rules exist, are correct, and never execute.
  • Scanning only the first navigation, which leaves every teardown failure — stale focus, orphaned inert, duplicated landmarks — permanently invisible.

FAQ

Do custom rules make automated scanning catch more than 30–40% of WCAG issues? Modestly, and only for criteria where a human has already decided what correct looks like. A custom rule can encode a component contract — this combobox owns this listbox, this table’s cells resolve to headers — and that genuinely moves specific failures from manual review into the pull-request gate. It cannot judge whether alt text is meaningful or whether a focus order makes sense in a novel widget, so the split shifts by a few points rather than being eliminated.

Should a custom rule live in the shared bundle or in the component’s own test? Put it in the shared bundle when the same failure can appear in markup written by teams who do not own the component, and keep it in the component’s test when only the component can produce it. A rule about aria-controls wiring belongs in the bundle because any consumer can copy the markup and break it; a rule about a component’s internal render order belongs in that component’s unit test, where the fixture is cheaper and the feedback is faster.

How do custom rules interact with axe’s built-in rules during a scan? They run in the same pass and appear in the same violations, passes and incomplete arrays, filtered by whatever tags or rule ids the run options specify. That means a custom rule can duplicate a built-in one and report the same node twice, which is why it is worth checking whether an existing rule with tuned options already covers the case before writing anything new — reducing overlap is the same exercise as reducing false positives in automated scanners.

What breaks when a custom check throws an exception in CI? axe catches the error and the whole run fails with a rule-level error rather than producing a clean 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 unit tests fast enough that they run before the browser scan — a thrown TypeError on a fixture costs seconds, and the same error in a scan costs a debugging session.

Can the same bundle run in Cypress and in component tests as well as Playwright? Yes, because the bundle is plain browser JavaScript that calls axe.configure() on the global axe instance. In Cypress, load the bundle after cy.injectAxe(); in a jsdom component test, import the bundle after requiring axe-core. The scan timing differs per runner, but the rule definitions and their expected results do not, which is exactly why the bundle is published as a pre-built browser file rather than as framework-specific glue.

In This Section