Accessibility · Forms and Errors

Wiring Error Messages to Their Fields

How aria-describedby, aria-invalid, and aria-errormessage bind a message to a control, and how the accessibility tree orders what the user hears.

An error message rendered on a page is text, and text has no address. When a form shows “Must be at least 8 characters” under a password field, the message and the field are adjacent in the layout and unrelated in the document model. A screen reader user who tabs back to the field hears its name, its value, and its state, and none of that includes the sentence below it. The message exists, is visible, and is unreachable from the control it describes.

Binding the message to the control is a matter of putting the message’s identifier into an attribute on the control. Which attribute, and what else has to change, is where most implementations diverge.

Description versus error property

Two ARIA mechanisms can carry an error, and they are not equivalent.

aria-describedby takes a space-separated list of element IDs and produces the control’s accessible description. It is a general-purpose slot: the same attribute carries format hints, units, and error text, and the strings are concatenated in ID order. The description is announced after the role, name, value, and state, which is the correct position for supplementary information.

aria-errormessage names a single element whose content is treated as the error message, and it is only consulted when the control also has aria-invalid="true". The restriction is deliberate: an error message should be announced only when there is an error, and tying the property to the invalid state prevents a stale message from being read as current. Support for aria-errormessage is less uniform than support for aria-describedby, and implementations that expose it may still fall back to the description slot.

The practical split that follows is that aria-describedby is the mechanism with universal support, and aria-errormessage is the more semantically precise one. A form that needs a single implementation across engines uses aria-describedby and manages the presence or absence of the referenced element itself: the hint paragraph and the error paragraph are separate elements, and the attribute points at whichever should be heard now.

The ordering problem

aria-describedby concatenates. If a field has both a persistent format hint and a transient error, and the attribute lists both IDs, the user hears the hint followed by the error, or the error followed by the hint, depending on the order in the attribute rather than on which is more urgent. Errors should come first, because the user’s next action is to fix the value, and the hint is context they may already have heard on the first visit.

The usual implementation is two elements with stable IDs and one attribute whose value is rewritten on each state change:

<label for="pw">Password</label>
<input id="pw" type="password" aria-describedby="pw-hint"
       aria-invalid="false">
<p id="pw-hint">At least 12 characters.</p>
<p id="pw-error" hidden>Password must contain a digit.</p>
function setError(input, messageEl, hintId, hasError) {
  input.setAttribute('aria-invalid', String(hasError));
  input.setAttribute(
    'aria-describedby',
    hasError ? `${messageEl.id} ${hintId}` : hintId
  );
  messageEl.hidden = !hasError;
}

Two details in that snippet are load-bearing. The hidden attribute removes the message from the accessibility tree, so a hidden message contributes nothing to the description even if its ID is listed, which means listing both IDs unconditionally would silently drop the hidden one and work by accident, until the message is made visible with CSS rather than with hidden and starts contributing while visually hidden. And aria-invalid is set to the string "false" rather than removed, because an explicit false states that the field has been evaluated and is currently valid, which is a different claim from the silence of an attribute that was never set.

What the announcement actually contains

The accessible description is a string, computed from the referenced elements’ text in the order the IDs are listed. Whitespace is normalised; nested markup contributes its text. The result is appended to the control’s announcement, after the value and state. For a text input that is roughly: role, name, value, invalid state, description.

The consequence is that the description is heard on every focus of the field, not once. A 30-word error message attached to a field the user visits five times is read five times. Short messages are therefore not a stylistic preference but a usability requirement: “Enter a date after 1 March” is a message, and “The date you have entered is not valid for this booking because the outbound journey must depart after the return journey” is a paragraph that has been put in the wrong slot.

The description also replaces the field’s value announcement in some screen reader configurations when the value is long. There is no length limit in the specification; the practical limit is the user’s patience.

Grouping errors without a field

An error that applies to a radio group or a checkbox set has no single control to attach to. Attaching the message to the first radio button means the user hears it on entry to the group and not when the group’s validity is what failed. Attaching it to every radio means hearing it once per option.

The workable pattern is to place the message in the group’s own container and reference it from each control only if the group is small, or to attach it to the group element when the group is a fieldset with a legend: the fieldset can carry aria-describedby pointing at the message, and the description is announced when focus enters the group. The group naming mechanics are in Fieldset and Legend Group Semantics. Where the group is a div with role="radiogroup", the same attribute works on the container.

aria-invalid has no group-level meaning that is consistently exposed. Setting it on the container is not the same as setting it on the controls, and the reliable option is to set it on each control in the group and to describe the group once.

The visible message and the accessible message

The message the user sees and the message the user hears should be the same string, and the common divergence is a visual design that separates a short visible message from a longer screen-reader-only one. Two messages for one error is a maintenance liability and an inconsistency that shows up in testing: the visible text says “Invalid date” and the description says “Enter a date in the format DD/MM/YYYY”, and a support call quotes one while the documentation quotes the other.

The other divergence is a message that is present in the DOM but positioned with CSS so that it is off-screen or transparent. That content is in the accessibility tree and is announced, which means the accessible message and the visible message differ in content rather than in styling. The hidden attribute and display: none remove content from the tree; visibility: hidden, opacity: 0, and off-screen positioning do not. A validation library that renders every possible error message up front and hides them with opacity produces a field described by all of them at once.

What the attribute does not do

aria-describedby does not move focus, does not cause an announcement by itself, and does not make the message discoverable to a user who never focuses the field. Setting the attribute on a field the user has already left produces no speech; the user has to return to the field to hear the description. This is the reason a summary of errors at the top of the form, with links to the fields, is a separate requirement rather than a redundant one; see Error Summaries and Focus Placement.

It also does not survive a re-render. A component that rebuilds its input element on every state change loses the attribute along with the element, and any focus that was on the old node is lost too. Frameworks that reconcile in place preserve it; frameworks or code that replaces the subtree do not, and the failure looks like a message that stops being announced after the first correction.

Finally, the ID reference is document-scoped and does not cross a shadow boundary. A message element rendered outside a custom element’s shadow root cannot be referenced from the input inside it, so a component that owns its input must also own the message, or accept the message text as a property and render both in the same root. This is the same constraint that applies to labels, described in Label Association and the Accessible Name of a Form Control, and it catches component libraries at the same place.

A minimal contract

The pieces that have to hold together for one field are small enough to state as a checklist: the control has a name from a label; the message element has a stable id; the control’s aria-describedby includes that id only while the message applies; aria-invalid is explicitly true or false rather than absent; the message element is removed from the tree when it does not apply; and the message is short, specific, and phrased as what to do rather than what went wrong. Every one of those is checkable without a screen reader, which is what makes the binding a markup problem rather than a testing problem.

References

  1. WAI-ARIA 1.2 — W3C
  2. Accessible Name and Description Computation 1.2 — W3C
  3. Understanding Success Criterion 3.3.1: Error Identification — W3C WAI