axe-core aria-errormessage error form field missing error message association
Fixes the axe-core aria-errormessage violation: form fields marked invalid that do not point at their error message. Use when an axe scan flags inputs failing the form-elements-must-have-an-accessible-error-message check. Not for labeling the fields themselves, and not for server-side validation logic.
axe-core aria-errormessage error form field missing error message association - how to fix it
TL;DR
When a field is marked invalid with aria-invalid="true", axe requires the field to reference its visible error message through aria-errormessage. Add aria-errormessage="email-error" to the input and give the error text element id="email-error". Only set aria-invalid="true" after validation actually fails, and keep the error message in the DOM (visually hidden until needed is fine) so the reference never dangles.
Screen reader behavior varies here: NVDA and JAWS announce the referenced message when the field receives focus after becoming invalid, while VoiceOver may announce it on the next interaction. Test with at least one desktop screen reader rather than trusting the axe pass alone, because a valid reference that points at an empty element still passes the rule while helping nobody.
The verbatim error looks like this:
axe-core 4.x
Rule: aria-errormessage (serious)
"Form elements must have an accessible error message"
Element: [input type="email" id="signup-email" aria-invalid="true"]
(no aria-errormessage attribute found)Steps
- Reproduce the single rule in isolation so you see only this violation:
npx @axe-core/cli https://example.com/signup --rules aria-errormessageExpected: the run reports the flagged input(s) with the rule id aria-errormessage, and nothing else.
- Find every input that sets aria-invalid="true" and check whether it also sets aria-errormessage. A common pattern is toggling aria-invalid on submit without wiring the message:
- Add the reference on the field and an id on the message:
[label for="signup-email"]Email[/label]
[input type="email" id="signup-email" aria-invalid="true" aria-errormessage="email-error"]
[p id="email-error" role="alert"]Enter a valid email address.[/p]The value of aria-errormessage must exactly match the id of the element holding the error text.
- Verify the association is announced: with a screen reader, focus the invalid field and confirm the error text is read. Then re-run the axe check:
- Re-run the single-rule scan:
npx @axe-core/cli https://example.com/signup --rules aria-errormessageExpected output: "0 violations" for that rule on the page.
- Add a regression guard in your component tests so future edits do not drop the attribute again: assert that any element with aria-invalid="true" also has a non-empty aria-errormessage pointing at an existing id.
- Check the timing of when aria-invalid is set. If your form sets aria-invalid="true" on blur for every field, users get error announcements before they finish typing. Set it on submit or after the field is touched and blurred with a bad value, and clear it (aria-invalid="false" or remove the attribute) as soon as the value validates.
- Confirm the error message element stays in the accessibility tree. If the message is removed from the DOM when the field becomes valid again, the aria-errormessage reference dangles. Keep the element mounted and toggle its visibility, or remove the aria-errormessage attribute at the same time you remove the message.
When to use
- Any axe-core run flags the rule id aria-errormessage on a form.
- A form marks fields invalid dynamically (aria-invalid="true") but screen reader users never hear the error text.
- An audit report lists 'Form elements must have an accessible error message' as a serious violation.
When not to use
- The field itself has no label or name (that is label-title-only, select-name, or aria-input-field-name territory).
- The validation logic is wrong or the message never appears (fix the app logic first).
- The error message lives in a toast or modal far from the field and cannot be referenced by id (restructure the DOM first).
Why it happens
Screen readers announce an invalid field's error message only through the programmatic link aria-errormessage provides. Without it, a sighted user sees red text under the field while a screen reader user hears 'invalid entry' with no explanation of what to fix. The rule exists because setting aria-invalid alone is a half-fix that leaves the user guessing.
This violation clusters in forms built with client-side validation libraries that were configured before aria-errormessage existed in axe's rule set. The library toggles aria-invalid because that is the documented pattern, but the error text is rendered as a sibling [p] with no programmatic link. From the library's perspective the form is 'accessible'; from the axe 4.x perspective it fails a serious rule. The gap is a versioning problem as much as a markup problem: older tutorials predate the rule.
Edge cases
- Error messages injected after validation: if the message element does not exist in the DOM yet, axe still fails. Render it hidden (e.g. with a visually-hidden class) from the start, or re-run axe after the message mounts.
- Multiple errors per field: aria-errormessage takes a single id. Point it at a container that holds all the messages for that field.
- aria-errormessage on non-invalid fields is ignored by assistive tech; only wire it when the field is actually invalid.
- Frameworks that re-render the form can drop the attribute on state change. Assert it in a component test after each render cycle.
- Error summaries at the top of the form do not satisfy this rule. A summary helps, but each invalid field still needs its own aria-errormessage reference.
- aria-describedby is not a substitute. It provides a description, not an error message, and assistive tech announces it differently. Use both when the field needs a hint and an error.
- Fields validated on the server after submit need the attributes set in the re-rendered HTML, not just in the client-side validation path.
Provenance
Resolved from the public thread: https://vectle.com/posts/pst_JTl2JaBgauTlnvdcVUqPpA