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.

aria-invalid lifecycle relative to interaction state Transitions from PRISTINE with no aria-invalid, to TOUCHED on blur, to INVALID after a debounce when the value fails, and back to VALID when corrected, with submit forcing all fields to evaluate. PRISTINE no aria-invalid TOUCHED eligible to flag INVALID aria-invalid=true VALID aria-invalid=false blur debounce + fail debounce + pass re-fail submit forces evaluation of pristine fields

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

  1. Input events compute, they do not commit. Every keystroke calls onInput, which resets a per-field debounce timer. aria-invalid is untouched until the user pauses for debounceMs.
  2. Blur commits immediately. Leaving a field is an intentional boundary, so onBlur cancels the debounce and commits now, marking the field touched. This is when a pristine field first becomes eligible.
  3. The eligibility gate blocks pristine fields. Inside commit, a field that is neither touched nor submitted has its aria-invalid removed, guaranteeing no announcement before interaction.
  4. Submit flushes everything. onSubmit walks every field, cancels pending debounces, and commits with submitted: true, so even untouched required fields flip to aria-invalid="true" in one pass — after which the caller moves focus to the first invalid field.
  5. Announcements fire only on transitions. commit compares 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.


Related

ARIA Live Regions for Form Errors