core accessibility principles for modern frameworks

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 Relationships
  • 3.3.1 Error Identification
  • 3.3.2 Labels or Instructions
  • 4.1.2 Name, Role, Value
The four parts of a described, invalid field A single form field broken into four connected parts. A label element is associated by the for attribute. A hint paragraph and an error paragraph each have their own id, and the input references both through aria-describedby with a space-separated list. The input also carries aria-invalid true. Annotations note that the error is listed last so it is announced last, and that aria-invalid must be removed when the field becomes valid again. <label for="email"> Email address kim@example id="email-hint" — We only use this for receipts. id="email-error" — Enter a complete email address. On the input aria-describedby="email-hint email-error" aria-invalid="true" id="email" — matched by the label Announcement order 1. "Email address, edit" 2. "We only use this for receipts" 3. "Enter a complete email address" List the error last: descriptions are read in the order given, and the error is what the user needs to act on.

Prerequisites

  • Fields that already have real <label> elements. aria-describedby supplements 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-invalid only while an error message is being shown for that field, and remove the attribute otherwise — do not leave aria-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 :invalid CSS 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.

Choosing between aria-describedby and aria-errormessage Three rows comparing the two attributes. On support, describedby is universal while errormessage is uneven across screen readers. On semantics, describedby says here is more information whereas errormessage says here is why this is invalid. On practice, the recommendation is to ship describedby always and add errormessage alongside it when the extra precision is wanted, since a screen reader that ignores errormessage still hears the message through describedby. Aspect aria-describedby aria-errormessage Support universal uneven Semantics "more information" "why this is invalid" Recommendation always ship it add alongside

Implementation Guidelines:

  • Ship aria-describedby unconditionally; it is what actually gets announced everywhere today.
  • Add aria-errormessage pointing at the same element if you want the extra precision — but note it only applies while aria-invalid="true".
  • Do not use aria-errormessage alone. 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-describedby for 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.
Which channel carries which kind of message Three message types mapped to channels. A per-field error travels through aria-describedby and is announced when the user reaches the field. A submit-time summary travels through one polite live region for the whole form and is announced once. An asynchronous check that resolves while the user is still in the field also uses the single polite region, never a per-field alert, so the interruption happens at most once. Per-field error → aria-describedby Announced when the user reaches the field, which is when they can act on it. Submit summary → one polite region "3 problems to fix" — announced once, with focus moved to the summary. Async check → the same polite region "That username is taken" — never a per-field alert firing mid-keystroke.

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-invalid must both disappear in the same update.
  • Inspect the ids. In DevTools, confirm every id in aria-describedby resolves 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-value catches references to missing ids; assert the association directly with Testing Library's toHaveAccessibleDescription.
  • 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-invalid and 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.