# Form success and error messages that are never announced

Source: https://easeweb.dev/learn/form-results
Topics: Live updates > Form results are announced (WCAG 4.1.3)
The fix: Write the result, success or failure, into a status region that is on the page and empty before the form is sent. Give the line under the form role="status", and leave focus on the Subscribe button.
Test with: screen reader, keyboard
References: https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html https://www.w3.org/WAI/WCAG22/Techniques/failures/F103 https://www.w3.org/WAI/WCAG22/Techniques/aria/ARIA22 https://www.w3.org/WAI/tutorials/forms/notifications/ https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/status_role

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 bookshop's footer has a small newsletter sign-up: "Sign up for our newsletter", an Email address field and a Subscribe button. The form sends in the background, so the page doesn't reload. A second later a line appears under it: "Thanks! Check your inbox to confirm." If the shop's mailing service is down, the same line says "Sorry, we could not sign you up. Please try again." instead. A sighted visitor reads it and moves on. A screen reader user presses Subscribe and hears nothing, whichever way it went. Focus is still on the button, the button still says Subscribe, and the line under it is never read. They press again and get a second confirmation email, or they leave without knowing whether they signed up.

## Why it fails

"Thanks! Check your inbox to confirm." is a status message: it tells people the result of what they did, and it doesn't take focus. It is one of the W3C's own examples for Success Criterion 4.1.3: after a user submits a form, text is added to it saying the form was submitted. The criterion asks that such a message can be found by assistive technology through its role or properties, so that it is announced without focus moving. Here the result goes into a plain `p`, with no `role="status"` and no `aria-live`, so the browser has no reason to tell the screen reader that anything changed. That is failure F103. The failure message is a status message too, and fails the same way.

## Who is affected

The message sits right under the form, so anyone looking at the form sees it, and only people who rely on what is spoken or shown in braille miss it. When the form is an embed from a mailing-list service, the same silence comes with it onto every page that carries it.

## What automation and AI miss

A scan loads the page and finds a form with a labelled field and a named button, and passes it. It doesn't send the form: on a live site that would sign someone up. So it never sees the result appear, and nothing in the empty paragraph says it will hold one. EaseWeb's audit reports a page that has a form and no live region anywhere as a lead to test by hand. A page that has a live region elsewhere, such as a cookie bar, gets no such lead, so the form still has to be sent by a person. AI assistants asked to fix this may add `aria-live` to the paragraph in the same step that fills it, so the region and its words arrive together. Only sending the form with a screen reader running, once with success and once with failure, shows what is said.

## Before: a result written into a plain paragraph

```html
<section aria-labelledby="signup-title">
  <h2 id="signup-title">Sign up for our newsletter</h2>
  <form id="signup">
    <label for="signup-email">Email address</label>
    <input id="signup-email" name="email" type="email" autocomplete="email" required>
    <button>Subscribe</button>
  </form>
  <p id="signup-message"></p>
</section>
```

```js
const signup = document.getElementById('signup');
const message = document.getElementById('signup-message');

signup.addEventListener('submit', async (event) => {
  event.preventDefault();
  try {
    await subscribe(signup.email.value);
    message.textContent = 'Thanks! Check your inbox to confirm.';
  } catch {
    message.textContent = 'Sorry, we could not sign you up. Please try again.';
  }
});
```

The paragraph is on the page from the start, but it is not a live region, so filling it changes what people see and nothing that is said. Focus stays on Subscribe, which hasn't changed, so there is nothing new for a screen reader to read there either.

## After: a status line under the form

```html
<section aria-labelledby="signup-title">
  <h2 id="signup-title">Sign up for our newsletter</h2>
  <form id="signup">
    <label for="signup-email">Email address</label>
    <input id="signup-email" name="email" type="email" autocomplete="email" required>
    <button>Subscribe</button>
  </form>
  <p id="signup-message" role="status"></p>
</section>
```

```js
const signup = document.getElementById('signup');
const message = document.getElementById('signup-message');

// Empty the line, then write a moment later, so the same words written twice are still a change.
function announce(text) {
  message.textContent = '';
  setTimeout(() => (message.textContent = text), 100);
}

signup.addEventListener('submit', async (event) => {
  event.preventDefault();
  try {
    await subscribe(signup.email.value);
    announce('Thanks! Check your inbox to confirm.');
  } catch {
    announce('Sorry, we could not sign you up. Please try again.');
  }
});
```

The paragraph that was already there gets `role="status"`, so the browser is watching it when its text changes. The screen reader finishes what it is saying, then reads "Thanks! Check your inbox to confirm." Focus stays on Subscribe, so people carry on from where they were. A failure is read the same way. `announce()` empties the line and writes the words a tenth of a second later, so a second failure with the same words is still a change. Without the pause, an offline request that fails at once could empty and refill the line in the same moment, and the repeat may not be read. The message stays under the form, where everyone who just pressed the button is already looking.

## Implementation decisions

**The region must exist first.** In a component framework, render the status line with the form, empty, never inside an `{#if}` that appears with the result. A region that arrives with its message is often not announced; [status messages that are shown but never announced](/learn/status-messages) shows that failure in full.

**Politeness.** `role="status"` waits until the screen reader has finished speaking, and that is enough here: the person has just pressed Subscribe and is waiting for the answer. Keep `role="alert"` for messages people must act on at once, as [alerts that cut off VoiceOver for routine updates](/learn/urgent-alerts) explains.

**While it sends.** Keep the button enabled and ignore presses while a send is on its way. Setting `disabled` on the focused button can make the browser drop focus to the page, and the person loses their place. If the service can be slow, write "Subscribing…" into the same status line and let the result replace it, as [loading spinners that screen readers never announce](/learn/loading-states) does with its loading message. Start it from a short timer that the answer cancels, so a quick answer is not announced twice.

**Errors in the fields.** An address the browser or the server rejects is an error in a field, not a result of the send: it belongs beside the field, tied to it, as in [form errors that don't say how to fix them](/learn/accessible-form-errors). A longer form that fails with several such errors needs a summary that names every field, as in [error summaries that don't say which fields failed](/learn/error-summary); the status line then says only that the form wasn't sent.

**A thank-you page.** A form that reloads or goes to a new page isn't a status message: the new page's title and heading tell people what happened. In a single-page app, a route change to a thank-you view must be announced as a new view, as [single-page app route changes that are never announced](/learn/route-changes) covers.

**Embedded forms.** Forms from mailing-list and marketing services bring their own script and their own success message, and some replace the form without moving focus. Test the embed on your page, and if its script can't be changed, check whether the service offers a setting to redirect to a thank-you page instead.

**Not fixes.** Moving focus to the message while the form stays on the page does get it read, but it pulls people away from the form they may want to correct, when the status line does the job without moving them. Moving focus is the fix only when the form is gone, as in the guide on forms replaced by a thank-you. Adding `aria-live` to the paragraph in the same step that writes the message is the same failure as the toast in that guide on status messages: the live region and its words arrive together. A `title` or an `aria-label` that changes on the button is not announced reliably and says nothing about failure.

## Verify the fix

1. Inspect the DOM before sending: the status line is present, empty, has `role="status"`, and is not hidden with `display: none` or `aria-hidden`.
2. With a screen reader on, send the form with a valid address: the result is announced once, after any speech in progress, and focus stays on Subscribe.
3. Go offline in developer tools and send twice: the failure is announced in words each time, and focus stays on Subscribe.
4. Slow the network down (a throttling preset such as Slow 4G) and press Subscribe: focus stays on the button while the form sends, and the result is announced when it arrives, not before.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the form component, its script or the embed changes.

## Limits

Live regions behave differently across screen readers and browsers. This pattern covers one short form that sends in place and stays on the page. A design that replaces the form with a thank-you also removes the button that had focus, so a status line alone is not enough there: focus has to move to the thank-you, as [forms replaced by a thank-you that is never announced](/learn/thank-you-focus) shows. Actions that aren't forms, such as add to cart, are covered in [Add to cart buttons that change a badge and say nothing](/learn/action-confirmations), and search results that update in place in [search and filter results that update without a word](/learn/search-results). Test your real forms, including embedded ones, with both a success and a failure and with the screen readers your users use.

---

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