Writing a Custom axe-core Rule for Form Validation
The built-in axe-core ruleset never checks that a field flagged invalid by the Constraint Validation API actually points a screen reader at its error text, so this recipe shows how to author, register, and run a custom rule that fails whenever aria-invalid="true" is missing a resolvable aria-describedby target.
When to Use This Custom-Rule Approach
A custom axe-core rule is worth the maintenance cost only when a validation invariant is specific to your components and no bundled rule expresses it. The bundled ruleset checks generic things — labels, contrast, region landmarks — but it has no opinion about the relationship your forms enforce between validity state and error wiring.
- Reach for a custom rule when you need to assert app-specific structure: “every
aria-invalid="true"field must have a non-empty, resolvablearia-describedby.” - Prefer the plain scan in axe-core Accessibility Testing when a built-in rule already covers the case — do not re-implement
labelorcolor-contrast. - If your goal is only to block regressions in CI, Automating axe-core Form Audits in CI is the better starting point; a custom rule plugs into that same pipeline once written.
- Use it when the invariant spans many forms and you want one machine-checkable definition rather than a checklist item in WCAG 3.3.1 Error Identification Checklist.
Authoring the Rule with axe.configure() and a Custom Check
The rule has two halves: a check whose evaluate returns true when a node satisfies the invariant, and a rule whose selector decides which nodes the check runs against. The baseline form below ships novalidate and drives state through a single manual reportValidity() call, mirroring the custom validity messages pattern; the rule then audits the DOM that submit produced.
// axe-form-rules.ts
import axe from 'axe-core';
// A check returns true when the node PASSES the invariant.
axe.configure({
checks: [
{
id: 'invalid-field-has-error-text',
// Runs once per node matched by the rule's selector.
evaluate(node: HTMLElement): boolean {
const ids = (node.getAttribute('aria-describedby') ?? '')
.split(/\s+/)
.filter(Boolean);
if (ids.length === 0) return false;
// Every referenced id must resolve to a non-empty element.
return ids.some((id) => {
const target = node.ownerDocument.getElementById(id);
return !!target && target.textContent!.trim().length > 0;
});
},
metadata: {
impact: 'serious',
messages: {
pass: 'Invalid field references non-empty error text',
fail: 'aria-invalid="true" field has no resolvable aria-describedby error',
},
},
},
],
rules: [
{
id: 'form-invalid-needs-describedby',
// Only audit fields the app has explicitly marked invalid.
selector: 'input[aria-invalid="true"], select[aria-invalid="true"], textarea[aria-invalid="true"]',
any: ['invalid-field-has-error-text'],
enabled: true,
tags: ['cat.forms', 'app'],
metadata: {
description: 'Ensures invalid form fields point to visible error text',
help: 'Invalid fields must reference resolvable error text via aria-describedby',
},
},
],
});
// The baseline that produces the DOM this rule audits.
export function wireForm(form: HTMLFormElement): void {
form.noValidate = true; // <form novalidate>: we own the timing.
form.addEventListener('submit', (event) => {
// Single manual pass over the Constraint Validation API.
for (const field of form.elements) {
if (!(field instanceof HTMLInputElement)) continue;
const invalid = !field.checkValidity();
field.setAttribute('aria-invalid', String(invalid));
const errorEl = document.getElementById(`${field.id}-error`);
if (errorEl) errorEl.textContent = invalid ? field.validationMessage : '';
}
if (!form.reportValidity()) event.preventDefault();
});
}
axe.run() Options Reference
These are the values you tune when invoking the rule; the rule and check ids come from the configuration above.
| Option | Type | Default | Purpose |
|---|---|---|---|
runOnly.values |
string[] |
all enabled rules | Restrict a run to ['form-invalid-needs-describedby'] |
rules[id].enabled |
boolean |
true |
Toggle this rule per run without re-configuring |
check.evaluate |
(node) => boolean |
— | Returns true when the node passes the invariant |
rule.selector |
CSS selector | — | Nodes the check is evaluated against |
rule.any |
string[] |
[] |
Passes if any listed check passes |
rule.impact (via check) |
'minor'|'moderate'|'serious'|'critical' |
check value | Severity attached to a violation |
context (arg 1 of run) |
selector / node | document |
Scope the audit to one form subtree |
Verification Steps
Confirm the rule fires on a broken field and stays silent on a correct one before wiring it into any gate.
// custom-rule.spec.ts (Vitest + jsdom)
import { describe, it, expect, beforeAll } from 'vitest';
import axe from 'axe-core';
import './axe-form-rules'; // registers the configuration
describe('form-invalid-needs-describedby', () => {
it('flags an invalid field with no error text', async () => {
document.body.innerHTML = `
<form novalidate>
<input id="email" aria-invalid="true" aria-describedby="email-error" />
<span id="email-error"></span>
</form>`;
const results = await axe.run(document, {
runOnly: { type: 'rule', values: ['form-invalid-needs-describedby'] },
});
expect(results.violations).toHaveLength(1);
expect(results.violations[0].id).toBe('form-invalid-needs-describedby');
});
});
Edge Cases & Failure Modes
The check runs before the error text renders. When asynchronous server checks populate the message after the audit, evaluate sees an empty node and reports a false positive. Await the visible error state — or the settled live region — before calling axe.run(), exactly as you would for accessible error messaging.
aria-describedby resolves to a hidden or empty node. A referenced id that exists but is display:none or whitespace-only passes a naive presence check yet helps no one. The evaluate above already trims textContent; extend it to reject hidden ancestors if your design toggles visibility rather than content.
Grouped fields under cross-field validation share one error. A password-confirmation pair may set aria-invalid on both inputs but describe only one. Decide deliberately whether the selector should match each field or the group’s fieldset, and document the choice so the rule’s intent stays legible.
Returning relatedNodes for Actionable Violations
A boolean pass/fail tells a developer that a field failed, not which wiring was wrong. axe-core gives every check a this context — an object with this.data() and this.relatedNodes() — so an evaluate that opts out of the arrow-function form can attach diagnostics that surface in the violation’s nodes[].any[].data payload and its relatedNodes array. That payload is what a reviewer reads in the CI log, so investing a few lines here pays back on every failure.
// A diagnostic-rich variant of the same check.
axe.configure({
checks: [
{
id: 'invalid-field-has-error-text',
// NOTE: a plain function, not an arrow — we need `this`.
evaluate(node: HTMLElement): boolean {
const raw = node.getAttribute('aria-describedby') ?? '';
const ids = raw.split(/\s+/).filter(Boolean);
// Partition referenced ids into resolvable vs. dangling.
const dangling: string[] = [];
const resolved: HTMLElement[] = [];
for (const id of ids) {
const el = node.ownerDocument.getElementById(id);
if (el && el.textContent!.trim().length > 0) resolved.push(el);
else dangling.push(id);
}
// `this.data` rides along in the JSON report; `this.relatedNodes`
// makes the dangling targets clickable in the HTML reporter.
// @ts-expect-error axe augments `this` at call time.
this.data({ describedby: raw, dangling });
// @ts-expect-error same augmentation.
this.relatedNodes(resolved);
return resolved.length > 0;
},
metadata: {
impact: 'serious',
messages: {
pass: 'Invalid field references non-empty error text',
// A templated message reads the data object above.
fail: 'aria-invalid field has no resolvable error (dangling: ${data.dangling})',
},
},
},
],
});
The ${data.dangling} interpolation is axe-core’s own message templating, not a JavaScript template literal — the string is stored verbatim and expanded when the reporter renders, so the exact ids that failed to resolve appear inline in the violation summary rather than forcing a manual DOM hunt.
Scoping the Audit for Speed on Large Forms
axe.run(document, …) walks the entire tree, and on a page with dozens of steps that cost is wasted when only one form changed. The first argument to run is a context that narrows the traversal: pass an element, a selector, or an include/exclude object and the rule’s selector is matched only inside that subtree. Scope to the form that just submitted and the audit stays proportional to the fields the user actually touched.
// Audit only the submitted form, and skip a noisy third-party widget.
async function auditForm(form: HTMLFormElement) {
const results = await axe.run(
{ include: [['#' + form.id]], exclude: [['.captcha-embed']] },
{ runOnly: { type: 'rule', values: ['form-invalid-needs-describedby'] } },
);
return results.violations;
}
Two habits keep these runs cheap. First, always pair the scoped context with runOnly so axe-core skips the built-in ruleset’s expensive passes — color-contrast in particular forces layout and dominates a full-document run. Second, debounce the audit to the settled state after reportValidity() rather than firing it on every input event; the rule asserts a post-submit invariant, so running it mid-typing only measures a DOM the user has not finished producing. When the same rule later runs headlessly in Automating axe-core Form Audits in CI, that scoping keeps the gate fast enough to block merges without becoming the slowest step in the pipeline.
Frequently Asked Questions
Can I ship a custom axe rule as a reusable plugin?
Yes. Wrap the axe.configure() call in a module and import it once per test process before axe.run(). Because configure() resets state, call it a single time at setup rather than per test, and export the rule id so callers can target it with runOnly.
Why does axe.configure() wipe my other rules?
By default configure() replaces the active configuration, which can drop the built-in ruleset. Pass your custom rules and checks alongside the defaults, or call configure() exactly once and rely on runOnly to scope individual runs instead of reconfiguring.
Does this replace schema validation like Zod?
No. Zod schema validation checks that submitted data is correct; a custom axe rule checks that the resulting UI is accessible. They operate on different layers, and a robust form uses both — schema logic for values, an axe rule for the accessibility wiring around them.
Should the check use any, all, or none in the rule definition?
The three arrays compose checks with different logic. A rule passes when every check in all passes, at least one check in any passes, and no check in none passes. This single-check rule uses any because one condition decides the outcome, but if you later split "has a describedby target" and "target is not hidden" into separate checks, move both into all so a violation is raised unless both hold.
Why does my rule report zero nodes instead of a violation?
A rule only evaluates nodes its selector matches, so a field the app never marked aria-invalid="true" is invisible to the check and lands in neither violations nor passes. If you expect a field to be audited but it appears in results.inapplicable, the bug is upstream in the code that sets aria-invalid — not in the rule. Assert against checkValidity() first, then trust the axe result.
Related Guides
- axe-core Accessibility Testing — the scan API this rule extends
- Automating axe-core Form Audits in CI — run the custom rule as a merge gate
- WCAG 3.3.1 Error Identification Checklist — the requirement this rule encodes
- Testing Form Error Messages with Playwright — asserting the error text the rule expects
- Managing Focus After Validation Failure — pairing accessible errors with focus management after a failed submit