aria-invalid is not a validity flag you mirror from your validation engine — it is an announcement trigger, and setting it too early or too often turns a screen reader into a stream of “invalid, invalid, invalid” that drives users out of the form.
The failure this page fixes: your form computes validity correctly, but a screen reader announces every field as invalid on page load, or re-announces the same error on every keystroke while the user is still typing. Both come from wiring aria-invalid to raw validity instead of to interaction state. The attribute must follow the touched/dirty lifecycle, not the millisecond-by-millisecond result of the validator.
This is the counterpart to the ARIA live regions for form errors work — the live region carries the message text, aria-invalid marks the field as the thing that message is about — and it depends directly on dirty and pristine state tracking to know when a field has actually been interacted with.
The timing rule
There are exactly three moments aria-invalid may legitimately change, and one long stretch where it must not:
- Never while pristine. A field the user has not touched must not carry
aria-invalid="true", even if it is empty-and-required and therefore technically invalid. Pristine required fields are incomplete, not erroneous. - On blur (touched). The first time a field loses focus, it becomes eligible. If it is invalid at that point, flip
aria-invalid="true"and announce. - On submit. Every remaining invalid field flips at once. This is the only moment a still-pristine field is allowed to become invalid, because submitting is the user asserting they are done.
- On correction. Once a field is
aria-invalid="true", it may drop to"false"as soon as it becomes valid — this transition should be announced so the user knows they fixed it.
The subtle part is the debounce. Even a touched field should not re-announce on every keypress. Compute validity as often as you like, but only commit aria-invalid and the live-region message after the user pauses.
Core implementation
The controller below separates three concerns that engineers usually collapse into one: computing validity (cheap, synchronous, runs on every input), deciding eligibility (touched or submitted), and committing the announcement (debounced). Only the third writes aria-invalid or the live region.
interface FieldA11yState {
touched: boolean; // has the field been blurred at least once?
submitted: boolean; // has the form been submitted at least once?
valid: boolean; // latest synchronous validity result
message: string; // human-readable error, empty when valid
}
class AriaInvalidController {
private timers = new Map<string, number>();
constructor(
private inputs: Map<string, HTMLElement>,
private liveRegion: HTMLElement, // aria-live="assertive" error summary
private debounceMs = 400
) {}
/**
* Called on every `input` event. Cheap: it only records validity and
* schedules a debounced commit. It never touches aria-invalid directly,
* so typing "aaa" does not flip the attribute three times.
*/
onInput(name: string, state: FieldA11yState): void {
const existing = this.timers.get(name);
if (existing) clearTimeout(existing);
const timer = window.setTimeout(() => {
this.commit(name, state);
this.timers.delete(name);
}, this.debounceMs);
this.timers.set(name, timer);
}
/**
* Called on `blur`. Blur is a deliberate "I'm done with this field"
* signal, so we commit immediately and cancel any pending debounce.
*/
onBlur(name: string, state: FieldA11yState): void {
const existing = this.timers.get(name);
if (existing) clearTimeout(existing);
this.timers.delete(name);
this.commit(name, { ...state, touched: true });
}
/**
* Called once on submit. Flushes every field synchronously — including
* still-pristine ones, which submit is allowed to evaluate — and lets
* the caller move focus to the first invalid field afterwards.
*/
onSubmit(states: Map<string, FieldA11yState>): void {
for (const [name, state] of states) {
const existing = this.timers.get(name);
if (existing) clearTimeout(existing);
this.timers.delete(name);
this.commit(name, { ...state, submitted: true });
}
}
private commit(name: string, state: FieldA11yState): void {
const el = this.inputs.get(name);
if (!el) return;
// Eligibility gate: pristine, un-submitted fields are never flagged.
const eligible = state.touched || state.submitted;
if (!eligible) {
el.removeAttribute("aria-invalid"); // stay neutral while pristine
return;
}
const wasInvalid = el.getAttribute("aria-invalid") === "true";
el.setAttribute("aria-invalid", state.valid ? "false" : "true");
// Announce only on a meaningful transition, not on every commit,
// so a field that stays invalid does not re-nag on each blur.
if (!state.valid && !wasInvalid) {
this.announce(state.message);
} else if (state.valid && wasInvalid) {
this.announce(`${name} is now valid.`);
}
}
private announce(message: string): void {
// Clear then set on the next frame so identical consecutive messages
// still register as a DOM change the live region will announce.
this.liveRegion.textContent = "";
requestAnimationFrame(() => {
this.liveRegion.textContent = message;
});
}
}
Step-by-step walkthrough
- Input events compute, they do not commit. Every keystroke calls
onInput, which resets a per-field debounce timer.aria-invalidis untouched until the user pauses fordebounceMs. - Blur commits immediately. Leaving a field is an intentional boundary, so
onBlurcancels the debounce and commits now, marking the fieldtouched. This is when a pristine field first becomes eligible. - The eligibility gate blocks pristine fields. Inside
commit, a field that is neither touched nor submitted has itsaria-invalidremoved, guaranteeing no announcement before interaction. - Submit flushes everything.
onSubmitwalks every field, cancels pending debounces, and commits withsubmitted: true, so even untouched required fields flip toaria-invalid="true"in one pass — after which the caller moves focus to the first invalid field. - Announcements fire only on transitions.
commitcompares the previous attribute value to the new one, so a field that stays invalid across two blurs announces once, and a corrected field announces its recovery.
Failure modes and edge cases
aria-invalid on load
Rendering server-computed validity straight into the attribute flags every empty required field on first paint.
// WRONG — flags pristine required fields on mount
el.setAttribute("aria-invalid", String(!field.valid));
// RIGHT — gate on interaction; pristine stays neutral
if (state.touched || state.submitted) {
el.setAttribute("aria-invalid", state.valid ? "false" : "true");
}
The message re-announces because textContent did not change
Live regions only announce on a DOM mutation. Writing the identical error string twice is a no-op, so a second failed blur says nothing. The clear-then-set-on-next-frame trick in announce forces a mutation. Do not skip the requestAnimationFrame; setting "" and the message in the same tick collapses to one mutation on some engines.
Debounce timer leaks on unmount
The timers map holds setTimeout handles. If the component unmounts mid-debounce, the callback fires against a detached element. Clear all timers in teardown:
destroy(): void {
for (const id of this.timers.values()) clearTimeout(id);
this.timers.clear();
}
assertive live region interrupts mid-typing
If the error region is aria-live="assertive" and you commit on input rather than on blur, every debounced update interrupts the screen reader’s echo of the character the user just typed. Prefer polite for validation messages, reserve assertive for the submit-time error summary, and always debounce.
Screen reader double-speaks on submit
If both aria-invalid flipping and the live-region message land in the same tick, JAWS may read the field’s new invalid state and the summary. Announce the summary from onSubmit, but let per-field aria-invalid changes be silent during a submit flush by suppressing announce when submitted is true and delegating the single summary announcement to the form controller.
Verification checklist
Frequently Asked Questions
When should aria-invalid first be set to true?
Never while the field is pristine. Set aria-invalid="true" only after the field has been touched (blurred at least once) or after a submit attempt. Setting it on load makes a screen reader announce fields as invalid before the user has typed anything, which reads as broken. The eligibility gate in commit enforces exactly this by removing the attribute from any field that is neither touched nor submitted.
How do I stop the screen reader announcing errors on every keystroke?
Debounce the aria-invalid write and the live-region message by 300 to 500 milliseconds of input inactivity, or defer them to the blur event entirely. Compute validity synchronously if you like, but only commit the aria-invalid attribute and the message once the user pauses, so the announcement fires once per intent rather than per character. The controller here schedules a per-field timer on input and flushes it on blur.
Should aria-invalid be removed or set to false when a field becomes valid?
Set aria-invalid="false" rather than removing the attribute. An explicit false is a stable, queryable state that keeps your toggle logic symmetric, and it lets a live region announce that a previously flagged field is now corrected. Removing the attribute is also valid per spec but complicates diffing and testing. The one time you do remove it is while the field is still pristine, to keep it fully neutral.