Associating Error Messages with aria-describedby
A red message under a field is invisible to a screen reader unless something connects the two. That connection is aria-describedby, and the reason it goes wrong so often is that it takes an id — a string that has to be generated, kept unique, kept in sync with a conditionally rendered element, and combined with any hint text the field already had.
This guide covers the wiring, the interaction with aria-invalid, and the id-management patterns that keep it correct in a component library. It implements one piece of Accessible Form Validation & Error States.
WCAG Success Criteria Addressed:
1.3.1 Info and Relationships3.3.1 Error Identification3.3.2 Labels or Instructions4.1.2 Name, Role, Value
Prerequisites
- Fields that already have real
<label>elements.aria-describedbysupplements a name; it never replaces one. - A validation layer that produces messages per field — see accessible inline validation timing and debouncing for when to run it.
- React 18+ for
useId, or an equivalent id generator that is stable across renders and unique per instance.
Wire the Ids, and Keep Them Stable
aria-describedby takes a space-separated list of ids. Every id in that list must exist in the DOM at the moment the field is read, or the reference is silently ignored.
Implementation Guidelines:
- Generate ids with
useId()inside the field component, so two instances of the same field on one page cannot collide. - Include the hint id always and the error id only when an error is rendered — a reference to a missing element is dropped, but a stale one confuses debugging.
- Order the list so the error comes last; descriptions are announced in list order and the actionable part should land last.
- Keep the message element in the DOM whenever the field is invalid, not only while it has focus.
export function Field({ label, hint, error, ...inputProps }: FieldProps) {
const id = useId();
const hintId = hint ? `${id}-hint` : undefined;
const errorId = error ? `${id}-error` : undefined;
const describedBy = [hintId, errorId].filter(Boolean).join(' ') || undefined;
return (
<div className="field">
<label htmlFor={id}>{label}</label>
<input
id={id}
// A11y rationale: 3.3.1 Error Identification — the message is announced
// with the field, so the user hears what is wrong without hunting.
aria-describedby={describedBy}
aria-invalid={error ? true : undefined}
{...inputProps}
/>
{hint && <p id={hintId} className="field-hint">{hint}</p>}
{error && <p id={errorId} className="field-error">{error}</p>}
</div>
);
}
aria-invalid Must Track the Truth
aria-invalid="true" tells the user the field's value is rejected. Left behind after a fix, it says the opposite of what is on screen.
Implementation Guidelines:
- Set
aria-invalidonly while an error message is being shown for that field, and remove the attribute otherwise — do not leavearia-invalid="false"scattered through the form, which is harmless but noisy. - Clear it the moment validation passes, in the same update that removes the message.
- Do not set it before the user has interacted; a form that announces every empty required field as invalid on load is hostile.
- Never rely on
:invalidCSS alone. The pseudo-class reflects constraint validation, not your validation logic, and it is invisible to assistive technology.
describedby or errormessage?
aria-errormessage exists specifically for this case and is the more precise tool — but support is still uneven enough that it should be an addition rather than a replacement.
Implementation Guidelines:
- Ship
aria-describedbyunconditionally; it is what actually gets announced everywhere today. - Add
aria-errormessagepointing at the same element if you want the extra precision — but note it only applies whilearia-invalid="true". - Do not use
aria-errormessagealone. Where it is unsupported the message is announced by nothing at all. - Keep both pointing at the same element so there is one message to maintain.
Live Regions Are Not the Answer Here
A tempting shortcut is to put field errors in a live region so they announce immediately. It produces a form that interrupts constantly and still fails to associate the message with the field.
Implementation Guidelines:
- Use
aria-describedbyfor per-field messages: they are announced when the user reaches the field, which is when they can act on them. - Reserve one live region for the form-level summary on submit, as described in the parent guide.
- If a message must be announced while focus is already inside the field — after an async check, say — a single polite region for the form is better than one per field.
- Never mark the error element
role="alert"while the user is typing in that field; the interruption lands mid-word.
How to Verify
- Focus each field. With a screen reader, Tab into an invalid field. You should hear the label, the hint, then the error, in that order.
- Fix and re-check. Correct the value; the message and
aria-invalidmust both disappear in the same update. - Inspect the ids. In DevTools, confirm every id in
aria-describedbyresolves to an element. A dangling id is the most common cause of "the message exists but nothing is announced". - Duplicate the form. Render the same field component twice on one page and confirm the ids differ — a hard-coded id string breaks both instances.
- Automated scan. axe's
aria-valid-attr-valuecatches references to missing ids; assert the association directly with Testing Library'stoHaveAccessibleDescription. - Check a field with both a hint and an error. Both must be announced, in that order, and neither should disappear when the other appears — the most common symptom of an overwritten description list.
- Toggle validity repeatedly. Break the field, fix it, break it again, and confirm
aria-invalidand the message appear and disappear together every time rather than drifting apart.
Common Accessibility Mistakes
- Hard-coded ids in a reusable field. Two instances on one page produce duplicate ids, and the second field describes the first field's error.
- Removing the message but leaving
aria-invalid="true". The field is announced as invalid with no reason given. - Overwriting an existing
aria-describedby. The hint disappears when an error appears; build the list from both. - Putting the error first in the list. The user hears the correction before the field's purpose, which reads backwards.
role="alert"on every field message. Each keystroke that changes validity interrupts whatever is being read.- Colour-only error styling. Without the message association, a red border tells a screen reader user nothing at all.
Conclusion
The association is two attributes and one rule about ids: generate them per instance, include the hint and the error in one space-separated list with the error last, and keep aria-invalid synchronised with whether a message is actually rendered. Do that inside a single Field component and every form in the codebase inherits it, including the ones written by people who have never heard of aria-describedby. Then verify the way a user would — Tab into a broken field and listen to what comes back.
One last structural note: this wiring belongs in exactly one component. A codebase where each form assembles its own label, hint, message and id relationships will drift within two sprints, and the drift is invisible until someone listens to it. Put the association in a single Field primitive, make every form use it, and the whole category of bug — dangling ids, overwritten descriptions, stale invalid states — stops being possible without a deliberate override. That is the difference between an accessible form and a codebase that produces accessible forms by default.
Frequently Asked Questions
Can aria-describedby reference multiple elements?
Yes, and it should. It takes a space-separated list of ids and announces them in the order given. That is exactly how a field carries both a persistent hint and a conditional error without either overwriting the other.
Does the description get announced on every focus? Yes, which is why descriptions should be short. A three-line explanation is re-read every time the user returns to the field. Keep the essential constraint in the hint and put longer guidance elsewhere on the page.
Should the error message element be role="alert"?
Not for per-field validation triggered by the user leaving the field — the message will be announced when they reach it. Use a single form-level region for submit-time summaries instead, so several errors are announced once rather than several times.
What about aria-required versus the required attribute?
Use the native required attribute; it is announced and it participates in constraint validation. Add aria-required="true" only when the native attribute cannot be used, such as on a composite widget built from <div> elements — and prefer fixing that instead.
How do I associate an error with a group of controls?
Put the group in a <fieldset> with a <legend> and place the message inside the fieldset, referenced from each control's aria-describedby — or from the group container if the widget uses role="radiogroup". The message needs to be reachable from whichever element receives focus.
Related guides
- Accessible Form Validation & Error States — the parent guide covering summaries and timing.
- Accessible Inline Validation Timing & Debouncing — when these messages should appear.
- Announcing React Hook Form Submission Errors — the same wiring inside a form library.
- Focus Management in Multi-Step Wizards — where the summary sends focus when a step is blocked.