Announce field errors without moving the caret
Canonical URL: https://devexamples.com/javascript/show-field-errors-with-aria-live/
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.
<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>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-liveto each field message so a failing submit produces several overlapping announcements. - Replacing the
aria-describedbytarget with a newly created node, which breaks the association until the attribute is re-applied. - Using
alert()or anaria-live="assertive"region for routine validation, which interrupts speech for a recoverable condition.
Caveats
role="status"is polite by default; do not also setaria-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.
Related
Validate form input before the browser submits itThe plain submit-time rules produce the failure list this page announces.
Related
Group related checkboxes with a fieldset and legendA grouped set of controls needs its own error target rather than one per field.
Alternative approach
Type the validation state of a controlled formThe same announcement task expressed as typed React field state.
References
- status (role)(opens in a new tab) — MDN. Defines the polite live region semantics used for the summary announcement.
- Form Validation Pattern(opens in a new tab) — W3C WAI. Describes associating messages with controls and announcing failure.
Source page: https://devexamples.com/javascript/show-field-errors-with-aria-live/