Announce field errors without moving the caret

The problem

A form that blocks submission but only shows errors in a place the reader is not looking at leaves keyboard and screen-reader users unable to tell which field failed or why, especially when the message appears after they have already moved on.

Short answer

Render each message into a paragraph already referenced by aria-describedby, keep a single polite live region for the summary, and never call focus() on the message yourself.

An error that appears silently is not an error the reader can act on. The useful pattern is to attach the message to the control it describes and announce only the fact that something changed.

Language: HTML
<form id="signup" novalidate>
  <label for="email">Email</label>
  <input id="email" name="email" type="email" aria-describedby="email-error" />
  <p id="email-error" class="error"></p>

  <button type="submit">Create account</button>
  <p id="form-status" role="status"></p>
</form>

Language: JavaScript
const form = document.getElementById('signup');
const email = document.getElementById('email');
const emailError = document.getElementById('email-error');
const status = document.getElementById('form-status');

const rules = [
  {
    field: email,
    message: (value) => (value.trim() === '' ? 'Email is required.' : ''),
  },
];

form.addEventListener('submit', (event) => {
  event.preventDefault();

  const failures = [];
  for (const rule of rules) {
    const message = rule.message(rule.field.value);
    const target = document.getElementById(`${rule.field.id}-error`);
    // Insert the text where the control already points with aria-describedby.
    target.textContent = message;
    rule.field.setAttribute('aria-invalid', message ? 'true' : 'false');
    if (message) failures.push({ id: rule.field.id, message });
  }

  if (failures.length === 0) {
    status.textContent = '';
    return;
  }

  // One polite announcement, not one per field, and no focus hijacking.
  status.textContent = `${failures.length} field(s) need attention. ${failures[0].message}`;
});

Explanation

The message node exists in the DOM before the failure, and the input already names it with aria-describedby. Writing textContent into that node means a screen reader that reaches the field afterwards reads the error with the label, because the relationship is static and does not depend on the announcement arriving first. Setting aria-invalid gives the technology a second, standard signal that the current value is not accepted, without inventing a class name it would have to guess.

The live region is deliberately separate and singular. Attaching aria-live to every field error makes a failing form announce a burst of messages in an order that reflects DOM position rather than importance, and it often re-announces text the reader has already seen. A single role="status" element has implicit polite behaviour, so the announcement waits for a pause in speech instead of interrupting the reader mid-word. The message names the count and quotes the first failure, which is actionable: it tells the reader how much work remains without reading the entire form.

No focus() call appears in the handler, and that is the point. Moving focus to an error summary is a common accessibility recommendation for long forms, but doing it unconditionally on every submit breaks the caret position of a reader who was mid-edit and can strand Voice Over users on a non-interactive paragraph. If a form is long enough to need it, move focus to a link or button in the summary so the reader has something to operate, and only when the failure count changes from zero.

The boundary worth knowing: a live region only announces content that changes while it exists. If your code creates the role="status" element at the same moment it fills in the text, most combinations of browser and screen reader will stay silent, because the region was not present when the mutation happened. Ship the empty element in the markup.

Common mistakes

  • Adding aria-live to each field message so a failing submit produces several overlapping announcements.
  • Replacing the aria-describedby target with a newly created node, which breaks the association until the attribute is re-applied.
  • Using alert() or an aria-live="assertive" region for routine validation, which interrupts speech for a recoverable condition.

Caveats

  • role="status" is polite by default; do not also set aria-live="polite", as duplicating the signal is noise for implementors reading the markup.
  • Clearing the message on every input event, rather than only after a failed submit, announces the same text repeatedly on some screen readers.

Next steps

Validate the same rules before this handler runs, and read the typed variant when the field state lives in React.

Related examples

Editorial links first, then deterministic same-task or same-topic candidates. Tags and shared language alone never qualify a candidate.

References