Linking Error Messages to Inputs with aria-describedby

When an inline error appears next to a field, a sighted user connects the two by proximity, but a screen reader only announces the message if the input programmatically points at it — this recipe wires that link with aria-describedby and aria-invalid so every error you show is also an error the assistive tech reads, forming the backbone of accessible error messaging.

When to Use This aria-describedby Approach

Reach for aria-describedby whenever an error message lives in a separate DOM node from the control it describes — the near-universal case for inline field errors. It is the association layer, not the timing or delivery layer:

  • Use it when the message is persistent text next to the input (the standard inline pattern).
  • Prefer it over stuffing the error into the field’s aria-label, which would clobber the accessible name and be re-read on every focus.
  • If your errors surface as transient popups instead of adjacent nodes, see When to Use a Toast vs an Inline Error — a toast is announced by a live region, not by aria-describedby.
  • Pair this with Best Practices for Inline Validation Timing to decide when the linked message appears; this page covers how it is connected.
Wiring an input to its error message with ARIA The field is validated, aria-invalid and aria-describedby are set to point at the error node, and the screen reader announces that message on focus. From validity state to an announced description checkValidity() read validationMessage aria-invalid + link describedby = error id screen reader reads name, then message
The association is one-directional: the input references the error node's id, and the reader resolves it on focus.

checkValidity() Plus aria-describedby: Minimal Implementation

The baseline is a <form novalidate> whose validity is driven by a single manual checkValidity() call, so the native error bubble never fires and you own both the visible text and its ARIA link. Each field owns a dedicated error node with a stable id; the controller toggles aria-invalid and swaps aria-describedby to include or exclude that id.

<form id="signup" novalidate>
  <label for="email">Email</label>
  <input id="email" name="email" type="email" required aria-describedby="email-hint" />
  <p id="email-hint" class="hint">We only use this to confirm your account.</p>
  <p id="email-error" class="field-error" hidden></p>
  <button type="submit">Create account</button>
</form>
// aria-describedby.ts — link each field to its error node on demand.
// Baseline: novalidate form + manual Constraint Validation API.

/** Merge or remove an id inside a space-delimited aria-describedby token list. */
function setDescribedBy(input: HTMLInputElement, id: string, present: boolean): void {
  const tokens = (input.getAttribute("aria-describedby") ?? "")
    .split(/\s+/)
    .filter(Boolean)
    .filter((t) => t !== id); // drop any stale copy first
  if (present) tokens.push(id);
  if (tokens.length) input.setAttribute("aria-describedby", tokens.join(" "));
  else input.removeAttribute("aria-describedby");
}

/** Reflect a single field's validity into the DOM and ARIA. */
function syncField(input: HTMLInputElement): boolean {
  const errorId = `${input.id}-error`;
  const errorNode = document.getElementById(errorId);
  if (!errorNode) throw new Error(`Missing error node #${errorId}`);

  const valid = input.checkValidity();
  if (valid) {
    input.removeAttribute("aria-invalid");
    errorNode.hidden = true;
    errorNode.textContent = "";
    setDescribedBy(input, errorId, false); // keep the persistent hint, drop the error
  } else {
    input.setAttribute("aria-invalid", "true");
    errorNode.textContent = input.validationMessage; // native-derived message text
    errorNode.hidden = false;
    setDescribedBy(input, errorId, true);
  }
  return valid;
}

const form = document.getElementById("signup") as HTMLFormElement;

// Live re-sync only after a field has already failed once, so we never
// nag mid-typing before the first submit.
form.addEventListener("input", (e) => {
  const input = e.target as HTMLInputElement;
  if (input.matches("input") && input.getAttribute("aria-invalid") === "true") {
    syncField(input);
  }
});

form.addEventListener("submit", (e) => {
  const fields = Array.from(form.querySelectorAll<HTMLInputElement>("input"));
  const allValid = fields.map(syncField).every(Boolean); // sync every field
  if (!allValid) {
    e.preventDefault();
    fields.find((f) => f.getAttribute("aria-invalid") === "true")?.focus();
  }
});

Note that the visible hint (email-hint) stays wired the whole time; only the error id is added and removed, so both descriptions coexist when the field is invalid. To replace the terse browser strings with your own copy, feed setCustomValidity per the custom validity messages recipe before reading validationMessage.

aria-describedby: Attribute and Option Reference

Option Type Default Purpose
aria-describedby string (space-separated id list) (unset) IDREF list the reader resolves as supplementary description after the accessible name.
aria-invalid "true" | "false" | (unset) (unset) Marks the control as failing; removing it (not setting "false") is cleanest when valid.
error node id string ${input.id}-error Stable target the field references; must be unique in the document.
error node hidden boolean true Hides the empty node; a hidden node referenced by aria-describedby is not announced.
role="alert" on error node string (omit here) Optional live-region escalation; use only for out-of-band errors, not per-field text.
persistent hint id string ${input.id}-hint Kept in the token list alongside the error so guidance survives the invalid state.

Verification Steps

// aria-describedby.spec.ts — Playwright
import { test, expect } from "@playwright/test";

test("invalid email links its error via aria-describedby", async ({ page }) => {
  await page.goto("/signup");
  await page.fill("#email", "not-an-email");
  await page.click("button[type=submit]");

  const email = page.locator("#email");
  await expect(email).toHaveAttribute("aria-invalid", "true");

  const describedBy = (await email.getAttribute("aria-describedby")) ?? "";
  expect(describedBy.split(/\s+/)).toContain("email-error");

  const errorText = await page.locator("#email-error").textContent();
  expect(errorText?.trim().length).toBeGreaterThan(0);
});

Edge Cases & Failure Modes

Referencing a hidden or empty node. An aria-describedby id that points at a node with hidden set or no text content is silently dropped by most readers, so the error goes unannounced. Set textContent before clearing hidden, and only add the id to the token list once the node actually holds copy.

Clobbering the persistent hint. Overwriting aria-describedby with just the error id destroys any always-on help text. Always treat the attribute as a token list — merge and remove single ids (as setDescribedBy does) rather than assigning the whole string.

Stale association after async validation. With asynchronous server checks, a slow response can toggle aria-invalid for a field the user already fixed. Re-read validity at resolution time and reconcile against the current value; the same discipline applies to cross-field validation, where one edit can clear another field’s error node.

Describing a Radio or Checkbox Group with a Shared Error

Single inputs are the easy case; a set of radio buttons or checkboxes that fails as a unit is where the association usually breaks. There is no single control to describe — the group is the semantic entity — so the error node must hang off the <fieldset> rather than any one option. The accessibility tree exposes a radiogroup through the <fieldset>/<legend> pairing, and aria-describedby on the fieldset is resolved when focus lands on any radio inside it, because the group’s description is inherited by its members.

// group-describedby.ts — link a fieldset error to a radio/checkbox group.
// The whole group succeeds or fails together, so we describe the container.

function syncRadioGroup(fieldset: HTMLFieldSetElement): boolean {
  const errorId = `${fieldset.id}-error`;
  const errorNode = document.getElementById(errorId);
  if (!errorNode) throw new Error(`Missing error node #${errorId}`);

  const radios = Array.from(
    fieldset.querySelectorAll<HTMLInputElement>('input[type="radio"]'),
  );
  const chosen = radios.some((r) => r.checked);

  if (chosen) {
    fieldset.removeAttribute("aria-invalid");
    errorNode.textContent = "";
    errorNode.hidden = true;
    setDescribedBy(fieldset as unknown as HTMLInputElement, errorId, false);
  } else {
    // aria-invalid on a group is advisory, but it keeps CSS and tooling aligned.
    fieldset.setAttribute("aria-invalid", "true");
    errorNode.textContent = "Choose one option to continue.";
    errorNode.hidden = false;
    setDescribedBy(fieldset as unknown as HTMLInputElement, errorId, true);
  }
  return chosen;
}

Two subtleties are worth flagging. First, aria-invalid on a <fieldset> is not a validity flag the browser computes — grouped required lives on the individual radios — so you set it purely to drive styling and to signal intent to tooling. Second, some older screen readers announce a fieldset’s description only on the first focusable member, so keep the error text short and front-load the actionable verb; a user tabbing straight to the second radio may never hear a long preamble.

Why the Description Is Not Re-announced on Change

A recurring surprise is that editing an already-focused field’s error text does not trigger a fresh announcement. aria-describedby is a static association, computed by the reader at focus time; it is not a live region, so mutating the referenced node’s textContent while focus stays put produces silence. This is by design — a description that re-read itself on every keystroke would be intolerable — but it means a message that changes from “Too short” to “Must contain a digit” while the caret never leaves the field is invisible to a screen-reader user until they blur and refocus.

If a mid-edit message genuinely must be spoken immediately, that is a job for a separate polite live region, not for the description node. Reserve the aria-describedby link for the settled, post-interaction state, and let visual feedback micro-interactions carry the moment-to-moment sighted feedback. The mental model: aria-describedby answers “what is wrong with this field right now?” when the user arrives at it, while a live region answers “something just changed” as it happens.

Frequently Asked Questions

Should I use aria-describedby or aria-errormessage for form errors?

aria-errormessage is semantically more precise, but it is only honoured when aria-invalid="true" is also set, and screen-reader support is still uneven. For maximum compatibility today, use aria-describedby — it is announced reliably everywhere. You can safely set both attributes if you want to progressively enhance.

Can one input point at multiple ids in aria-describedby?

Yes. aria-describedby takes a space-separated list of IDREFs and the reader concatenates them in order. That is exactly why the recipe keeps a persistent hint id alongside the error id — both descriptions are announced. Keep the order intentional, since it controls the reading sequence.

Does aria-describedby move keyboard focus to the error?

No. It only creates a description association that is read when the input itself receives focus; it never moves focus on its own. Sending the user to the first invalid control is a separate concern covered in focus management after a failed submit.

Why doesn't my screen reader announce the error while I keep typing?

Because aria-describedby is resolved once, when the control gains focus, and is not a live region. Changing the referenced node's text while focus stays on the field produces no announcement. That is intentional — a constantly re-read description would be unusable — so route any must-hear-now message through a separate polite live region and let the description carry the settled state.

← Back to Inline Error Messaging Strategies