# Inline form errors read out while typing, or not at all

Source: https://easeweb.dev/learn/inline-validation
Topics: Live updates > Errors that appear while typing are announced at the right moment (WCAG 4.1.3, 3.3.1)
The fix: Check the field when it has been changed and left, or once typing has paused for about a second, never on every key. Either way, write the error into a role="status" element under the field that is on the page from the start, tie it to the field with aria-describedby, set aria-invalid at the same moment, and clear it as soon as the value passes.
Test with: screen reader, keyboard
References: https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html https://www.w3.org/WAI/WCAG22/Understanding/error-identification.html https://www.w3.org/WAI/WCAG22/Techniques/aria/ARIA21 https://www.w3.org/WAI/tutorials/forms/notifications/ https://www.nngroup.com/articles/errors-forms-design-guidelines/ https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-invalid

This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.

## What happens

A tea shop's checkout asks for an email address first, with a phone number under it. The address is checked in the browser, so a mistake shows under the field before the order is sent: "Enter an email address in the correct format, like name@example.com". Sighted shoppers see it appear in red. What a screen reader user hears depends on when the check runs, and the shop has been built both of the ways many forms are.

In the first, the field is checked on every key, and the message is sent as an alert. A screen reader user types "s", hears the letter, and then, over it, "Enter an email address in the correct format, like name@example.com". They have typed one letter. A few keys later "sam@s" is a valid address, so the message goes; at "sam@shop," it fails again, and the alert is read again, over the letters typed after it. The person can't tell an error about the address they are typing from an error about the address they meant to type.

In the second, the field is checked only when it is left, and the message appears in a plain paragraph under it. The same person types "sam@shop,com", with a comma for the dot, and presses Tab. The screen reader says "Phone number (optional), edit text", and nothing else. The error is on screen, under a field they have left. If they go back, they hear "Email address, edit text, required, sam@shop,com", with no sign that anything is wrong. They find out when the order won't send.

## Why it fails

An error that appears without moving focus is a status message: Success Criterion 4.1.3 defines status messages as changes that report the results of an action "or the existence of errors", and asks that they can be presented to people through role or properties without receiving focus. Its Understanding page adds that this should happen "in a way that doesn't unnecessarily interrupt their work", and lists using `role="alert"` or `aria-live="assertive"` on content that is not important and time-sensitive as a failure. In the first version, every keystroke runs the check, and each time the message changes it is written into an alert. An error about an address still being typed is neither important nor time-sensitive: it reports a state the person is about to change. The browser does exactly what the page asks: an alert region is read as soon as its text changes, and nothing in it knows the person is in the middle of a word.

In the second version, the message is drawn into a `p` with no role, so its arrival is a status message that nothing announces, and 4.1.3 fails the other way. The purpose of Success Criterion 3.3.1 is missed too. It asks that, when an input error is detected automatically, the item in error is identified and the error described in text, and its Understanding page says the point is that people are aware an error has occurred and can tell what is wrong. Here the description exists, but only as text drawn under the field. Nothing ties it to the field: there is no `aria-invalid` and no `aria-describedby`, so the field is read as if nothing were wrong. The Understanding page doesn't make that markup a requirement of 3.3.1, but without it, or a live region, the only way to learn of the error is to read on past the field line by line, and nobody does that after a Tab that seemed to go fine.

## Who is affected

Checkout and sign-up forms are where this is met, often on a phone, where an error under the field may also be behind the on-screen keyboard.

## What automation and AI miss

A scan loads the form and finds a labelled field and an empty paragraph under it. It never types, so it never sees an error appear, let alone when. EaseWeb's audit does type: it enters four letters into an email field and presses Tab, and raises a lead for review when a live region in the form is written while the keys go in, or when new text appears in the form after Tab outside every live region. A person then checks whether that text is the field's error, and what a screen reader says. In the first version a scan may even find markup that looks right: `aria-invalid`, `aria-describedby` and `role="alert"`, which is what React Hook Form's accessibility example shows, and what an AI assistant asked to make form errors accessible can add. What's wrong is the moment the code runs, and only typing into the field with a screen reader on shows it.

## Before: an error checked on every key, sent as an alert

```html
<label for="email">Email address</label>
<input type="email" id="email" name="email" required aria-describedby="email-error">
<p id="email-error" role="alert"></p>
…
```

```js
function errorFor(field) {
  if (field.validity.valueMissing) return 'Enter your email address';
  if (field.validity.typeMismatch) return 'Enter an email address in the correct format, like name@example.com';
  return '';
}

function check() {
  const message = errorFor(email);
  email.setAttribute('aria-invalid', message ? 'true' : 'false');
  emailError.textContent = message;
}

email.addEventListener('input', check);
```

The message is right, and it is tied to the field. When it is said is wrong:

- The check runs on every key, so the error appears at the first letter of an address that isn't finished.
- It comes and goes as the address passes and fails while it is typed, and each time it comes back it is read again.
- It is sent as an alert, which may be read over the letters being typed.

## Before: an error checked when the field is left, shown in silence

```html
<label for="email">Email address</label>
<input type="email" id="email" name="email" required>
<p id="email-error"></p>
…
```

```js
function errorFor(field) {
  if (field.validity.valueMissing) return 'Enter your email address';
  if (field.validity.typeMismatch) return 'Enter an email address in the correct format, like name@example.com';
  return '';
}

function check() {
  emailError.textContent = errorFor(email);
}

email.addEventListener('blur', check);
```

The error waits until the address is finished, which is the right moment. But:

- The paragraph is not a live region, so the error appears and nothing reads it.
- The field is not marked invalid and nothing points it at the message, so going back to it reads no error.

## After: check when the field is left, and say it once

```html
<label for="email">Email address</label>
<input type="email" id="email" name="email" required aria-describedby="email-error">
<p id="email-error" role="status"></p>
…
```

```js
function errorFor(field) {
  if (field.validity.valueMissing) return 'Enter your email address';
  if (field.validity.typeMismatch) return 'Enter an email address in the correct format, like name@example.com';
  return '';
}

function check() {
  const message = errorFor(email);
  email.setAttribute('aria-invalid', message ? 'true' : 'false');
  if (emailError.textContent !== message) emailError.textContent = message;
}

// Check once the address is finished: when it has changed and the field is left.
email.addEventListener('change', check);

// While an error shows, take it away as soon as the address passes.
email.addEventListener('input', () => {
  if (emailError.textContent && !errorFor(email)) check();
});
```

1. Run the check on `change`. It fires when the field is left after its value changed, so nothing is checked while the keys go in, and tabbing past a field nobody typed in marks nothing as wrong.
2. Give the message's paragraph `role="status"`, on the page and empty from the start. A polite region asks the screen reader to read the error once, after it has finished what it is saying, usually the next field's name, though that name can cut it short (see Limits).
3. Point the field's `aria-describedby` at the paragraph, and set `aria-invalid` in the same step, so going back to the field reads it as invalid, with its error.
4. Write the message only when it changes, and on `input` only clear it, once the address passes. Correcting the address takes the error away at once, and nothing new is said while the keys go in.

This fixes both Befores and passes 4.1.3 and 3.3.1. Each error is said once, after the person has finished with the field, without moving focus, and stays tied to the field.

## After: check while typing, and say it when typing pauses

```html
<label for="email">Email address</label>
<input type="email" id="email" name="email" required aria-describedby="email-error">
<p id="email-error" role="status"></p>
…
```

```js
function errorFor(field) {
  if (field.validity.valueMissing) return 'Enter your email address';
  if (field.validity.typeMismatch) return 'Enter an email address in the correct format, like name@example.com';
  return '';
}

function check() {
  const message = errorFor(email);
  email.setAttribute('aria-invalid', message ? 'true' : 'false');
  if (emailError.textContent !== message) emailError.textContent = message;
}

// Check as people type, but only once they pause.
let pause;
email.addEventListener('input', () => {
  clearTimeout(pause);
  if (!errorFor(email)) check();
  else pause = setTimeout(check, 1000);
});
```

1. On each key, restart a one-second timer, and run the check when it ends. A person typing at their own speed hears nothing until they stop.
2. When the address passes, check at once, so the error goes as soon as it is corrected.
3. Give the message's paragraph `role="status"`, on the page and empty from the start, so the error is read once, after whatever the screen reader is saying.
4. Point the field's `aria-describedby` at the paragraph, and set `aria-invalid` in the same step. Write the message only when it changes, so a second pause on the same mistake says nothing new.

This fixes both Befores and passes 4.1.3 and 3.3.1. Someone who stops for a second in the middle of an address will be told it is incomplete; on a field like this, that is the cost of the earlier feedback.

## Which option to use

Both pass, and both say an error once, when the person has stopped. Checking when the field is left suits most fields, an email address among them: nobody is helped by hearing that an address is incomplete while they type it, and the error comes when they have said they are done. Checking on a pause suits fields with rules people work towards as they type, such as a new password or a username that must be free: they hear the result each time they stop to see where they are, rather than only after leaving. Many forms use both, field by field, with the same message element.

## Implementation decisions

**`change`, not `blur`.** Keyboard and screen reader users often Tab through a form before filling it in, to learn what it asks. A check on `blur` runs as they pass each field, and marks every empty required field as wrong before they have typed anything. `change` fires only when the value has changed. A required field left empty is caught when the form is sent: run the same check then, and move focus to the first field still wrong, where it is read with its error, or show an error summary. See [error summaries that don't say which fields failed](/learn/error-summary).

**The message has to make sense on its own.** It is read after the next field's name, not with its own field, so it names what it is about: "Enter an email address…", not "Invalid format". [Form errors that don't say how to fix them](/learn/accessible-form-errors) covers the wording.

**Render the message element with the page.** A status region that arrives along with its text is often not read. In a component framework, render the error element empty and keep it mounted, rather than inside an `{#if}` or a `{errors.email && …}` that adds it with the message.

**When `role="alert"` is right.** W3C's technique ARIA19 writes errors into `role="alert"` when the form is sent: the person has said they are done, and must act before they can go on. An error found while they are still filling the form in is not that, so it goes to a polite region.

**Form libraries.** Most libraries choose the moment for you, and the setting is easy to miss:

- React Hook Form checks on submit by default, then on every change after that; its `onBlur` and `onTouched` modes check when a field is left, and `onTouched` checks every change after the first time the field is left. Its accessibility example puts the message in `role="alert"`; use `role="status"` and keep the element rendered.
- Formik checks on every change and on blur, and its tutorial shows an error only for fields that have been visited. The message element it shows is a plain `div` with no role, tied to nothing.
- In any library, write the message element yourself if its own component adds an alert, and give the field `aria-describedby` and `aria-invalid` from the library's error state.

**Not before the field is used.** Don't render a field with `aria-invalid="true"` because it is empty and required. Screen readers read "invalid entry" on a field nobody has touched, with no message to explain it. Set it from the check, and only once the check has run.

**Not fixes.** Moving focus back to the field when it fails stops people leaving it, and keyboard users can be stuck there. Making the message element `aria-live="assertive"` instead of `role="alert"` is the same failure. Leaving the message tied to the field but in no live region, and counting on people going back, is the second Before with better markup: nothing tells them to go back.

**Character counters** are another message that changes as people type, and have the same answer: [character counters that stay silent or speak on every key](/learn/character-count).

## Verify the fix

1. Inspect the field before typing: its error element has `role="status"` and no role of alert, is in the DOM and empty, the field's `aria-describedby` lists its id, and the field has no `aria-invalid="true"`.
2. With a screen reader on, type an email address with a comma for the dot at your usual speed: nothing about errors is read while the keys go in.
3. Press Tab: the next field is read, then the error, once. Press Shift + Tab: the field is read as invalid, with its error. If the field checks on a pause, also stop for a second in the middle of the address: the error is read once, and not again until it changes.
4. Correct the address: the error goes as soon as it passes. Reload, and Tab through the whole empty form without typing: no field is marked or read as wrong.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the form library, its settings or the field components change.

## Limits

A polite message written as focus moves is read after the next field's name, or cut short by it, depending on the screen reader and how much that field has to say, so the error is also tied to the field, to be read when people return to it. This guide covers one field checked in the browser. What the message says, and how a field shows it is wrong, is covered in [form errors that don't say how to fix them](/learn/accessible-form-errors); the list of errors when the form is sent in [error summaries that don't say which fields failed](/learn/error-summary); and the result of a form sent in place in [form success and error messages that are never announced](/learn/form-results). Test your real form, with its library and its server checks, with the screen readers your users use.

## What screen readers do (tested by hand by EaseWeb)

`role="status"`: A polite live region: its changes wait until the screen reader has finished what it is saying.

| Screen reader | Browser and system | Down Arrow | Say all |
| --- | --- | --- | --- |
| JAWS | Chrome, Windows 11 | Not tested | Not tested |
| NVDA | Chrome, Windows 11 | Not tested | Not tested |
| JAWS | Edge, Windows 11 | Not tested | Not tested |
| NVDA | Firefox, Windows 11 | Not tested | Not tested |
| NVDA | Edge, Windows 11 | Not tested | Not tested |
| JAWS | Firefox, Windows 11 | Not tested | Not tested |
| VoiceOver | Safari, macOS | Not tested | Not tested |
| VoiceOver | Safari, iOS | Not tested | Not tested |
| TalkBack | Chrome, Android | Not tested | Not tested |

`role="alert"`: An assertive live region: ARIA asks for its changes to be presented at once.

| Screen reader | Browser and system | Down Arrow | Say all |
| --- | --- | --- | --- |
| JAWS | Chrome, Windows 11 | Read once the chunk being read ends (2026-10) | Not read (2026-10) |
| NVDA | Chrome, Windows 11 | Read once the paragraph ends (2026-10) | Read once the paragraph ends (2026-10) |
| JAWS | Edge, Windows 11 | Couldn’t test: Edge froze each time this was tested (2026-10) | Couldn’t test: Edge froze each time this was tested (2026-10) |
| NVDA | Firefox, Windows 11 | Not read (2026-10) | Not read (2026-10) |
| NVDA | Edge, Windows 11 | Read once the paragraph ends (2026-10) | Read once the paragraph ends (2026-10) |
| JAWS | Firefox, Windows 11 | Not tested | Not tested |
| VoiceOver | Safari, macOS | Not tested | Not tested |
| VoiceOver | Safari, iOS | Not tested | Not tested |
| TalkBack | Chrome, Android | Not tested | Not tested |

`aria-invalid="true"`: Marks a field whose value failed a check.

| Screen reader | Browser and system | Tab to it | Down Arrow |
| --- | --- | --- | --- |
| JAWS | Chrome, Windows 11 | Not tested | Not tested |
| NVDA | Chrome, Windows 11 | Not tested | Not tested |
| JAWS | Edge, Windows 11 | Not tested | Not tested |
| NVDA | Firefox, Windows 11 | Not tested | Not tested |
| NVDA | Edge, Windows 11 | Not tested | Not tested |
| JAWS | Firefox, Windows 11 | Not tested | Not tested |
| VoiceOver | Safari, macOS | Not tested | Not tested |
| VoiceOver | Safari, iOS | Not tested | Not tested |
| TalkBack | Chrome, Android | Not tested | Not tested |

`aria-describedby`: Gives an element a description, such as a hint or an error, from text elsewhere on the page.

| Screen reader | Browser and system | Tab to it | Down Arrow |
| --- | --- | --- | --- |
| JAWS | Chrome, Windows 11 | Not tested | Not tested |
| NVDA | Chrome, Windows 11 | Not tested | Not tested |
| JAWS | Edge, Windows 11 | Not tested | Not tested |
| NVDA | Firefox, Windows 11 | Not tested | Not tested |
| NVDA | Edge, Windows 11 | Not tested | Not tested |
| JAWS | Firefox, Windows 11 | Not tested | Not tested |
| VoiceOver | Safari, macOS | Not tested | Not tested |
| VoiceOver | Safari, iOS | Not tested | Not tested |
| TalkBack | Chrome, Android | Not tested | Not tested |

https://easeweb.dev/learn/screen-reader-support

---

Get this guide in your coding agent: MCP https://easeweb.dev/mcp (setup at https://easeweb.dev/services/developers)
