Skip to main content
ASoc
Tutorial

React Toast Notifications: Five Live Regions That Announced Nothing

Zero toast libraries and five aria-live regions here — all five mounted with their first message, so none of them ever announced. The audit, and the atom that fixed it.

The ASoc Team10 min read

A React toast notification is a transient message rendered outside the flow of the thing that triggered it, announced to assistive technology through a live region. The library question is the easy half. The hard half is that a live region only announces a change if it was already in the DOM before the change — and this codebase had five that were not.

We ship zero toast libraries. package.json has 13 runtime dependencies and none of them is react-toastify, react-hot-toast, sonner or notistack. What we ship instead is five inline status messages, one per form, each wired with aria-live="polite". This post is the audit of those five, the defect all five shared, and the fix that shipped with it.

Toast versus inline status: they are not the same control

The word "toast" gets applied to any small confirmation message, which hides a real decision. A toast is defined by three properties, and each one costs something:

PropertyToastInline status (what we use)
PositionFixed overlay, detached from the triggerIn flow, directly under the control that caused it
LifetimeAuto-dismisses after N secondsPersists until the next submit
Focus relationshipNone — the user may be anywhere on the pageAdjacent to the element the user just left
AnnouncementNeeds a live region, alwaysNeeds a live region, always
Failure modeMessage disappears before it is readMessage stays until it is stale

The row that decides it is lifetime. An auto-dismissing message is a race between your timer and the reader. A screen reader user who is mid-sentence when the toast fires, a user with a cognitive disability re-reading the text, a user who tabbed away to look something up — all of them lose the message when the timer wins. WCAG 2.2.1 (Timing Adjustable) exists because of exactly this, and the usual "5 seconds is plenty" is an assumption about the reader, not a fact about the content.

Every message in this codebase is the result of a form submission the user is standing in front of. There is nothing to detach and nothing to expire. So we do not toast, and the only genuinely transient thing on the site — the bulk download in src/components/molecules/DownloadMenu.tsx, which staggers its requests through hidden iframes — reports progress by disabling its own button rather than by floating a message somewhere else.

The defect: five live regions, none of them monitored

Here is what all five forms looked like. This is src/components/molecules/ContactForm.tsx, and AuthCard.tsx, NewsletterForm.tsx, SettingsForm.tsx and RedemptionPicker.tsx were character-for-character the same shape:

{state && (
  <p
    aria-live="polite"
    className={`text-sm ${state.ok ? "text-success-600" : "text-red-500 dark:text-red-400"}`}
  >
    {state.message}
  </p>
)}

That looks correct, and it passes every automated check we run. It is still wrong, for a reason that has nothing to do with the attribute and everything to do with when the element exists.

A live region works by mutation observation. The browser exposes the element to the accessibility tree, the screen reader registers it as a region to watch, and when the text inside it changes the change is announced. That sequence needs the region to be there first. {state && …} mounts the region and its message in the same commit: before the submit there is no element at all, and after it there is a brand-new subtree. The screen reader was never watching anything, so most combinations announce nothing — this is documented in the ARIA Authoring Practices, which says plainly that the live region container must be present in the DOM before the content is inserted.

useActionState makes this especially easy to get wrong. state is null until the first submit resolves, so the natural React idiom for "render this once there's something to say" is exactly the idiom that breaks the announcement.

Why nothing caught it

The same reason our accordion audit gives, one layer deeper. axe-core — what Lighthouse runs — validates the ARIA you wrote: is aria-live a legal attribute here, is polite a valid value, does the role nest correctly. All yes. It cannot check ordering across time, because a static scan of one rendered page has no notion of "this element did not exist a moment ago."

Four of the five forms are also on routes that no page audit reaches. /dashboard and /dashboard/settings are ƒ in the build output — server-rendered behind a login — and /login, /signup, /forgot-password and /reset-password are noindex by design. Only ContactForm (on /contact) and NewsletterForm (in the footer of every page) sit on the eight routes measured to accessibility 100 in LIGHTHOUSE.md. Two of them scored 100 with a live region that announced nothing.

The fix

The region moves into an atom, src/components/atoms/FormStatus.tsx, and is rendered unconditionally:

export default function FormStatus({
  state,
  className,
  okClassName = "text-success-600",
  errorClassName = "text-red-500 dark:text-red-400",
}: {
  state: { ok: boolean; message: string } | null | undefined;
  className?: string;
  okClassName?: string;
  errorClassName?: string;
}) {
  return (
    <>
      <p aria-live="polite" className="sr-only">
        {state?.message ?? ""}
      </p>
      {state && (
        <p
          aria-hidden="true"
          className={`text-sm ${state.ok ? okClassName : errorClassName}${className ? ` ${className}` : ""}`}
        >
          {state.message}
        </p>
      )}
    </>
  );
}

Two nodes rather than one, and the reason is layout arithmetic rather than accessibility theory. Four of the five call sites lay their fields out with flex flex-col gap-4 (gap-3 in RedemptionPicker). A gap applies between flex items whether or not an item has content, so an always-rendered in-flow <p> would have added 16px of dead space under every one of those forms before the first submit. sr-only in this codebase's Tailwind build is position: absolute among other things, and an absolutely positioned child is not a flex item at all — it contributes nothing to the gap calculation. The always-mounted region is therefore free, and the visible copy stays conditional exactly as before.

aria-hidden="true" on the visible paragraph is the other half. Without it the message exists twice in the accessibility tree: once announced by the region, once read again when the user reaches it in reading order.

Each call site collapses to one line:

// src/components/molecules/ContactForm.tsx
<FormStatus state={state} />

with the two variants that needed them passing props instead of forking the component — NewsletterForm sits on the dark footer and keeps its text-red-400 and mt-2:

<FormStatus state={state} className="mt-2" errorClassName="text-red-400" />

If you do need a real toast

Some things genuinely are toasts: a background job finishing, a websocket event, an undo affordance after a destructive action. If you build one, the same rule decides its correctness — mount the container at the app root, empty, and push text into it:

// The container renders once, on every page, with nothing in it.
export function ToastRegion({ message }: { message: string | null }) {
  return (
    <div
      aria-live="polite"
      aria-atomic="true"
      className="pointer-events-none fixed inset-x-0 bottom-4 flex justify-center"
    >
      {message && (
        <div className="pointer-events-auto rounded-lg bg-gray-900 px-4 py-3 text-sm text-white">
          {message}
        </div>
      )}
    </div>
  );
}

The live region is the wrapper, not the toast. The toast comes and goes inside a region that never unmounts. aria-atomic="true" makes the reader announce the whole message rather than only the changed words, which matters once one toast replaces another.

And if the message reports a failure the user did not initiate, role="alert" is the right container instead — it is assertive, interrupting whatever is being read. Do not switch a single node between polite and alert per message: changing the role changes the node's identity to the accessibility tree, which is the remount bug again wearing a different hat. Two regions, one of each, both always mounted.

Do you need a library?

You needHand-rolled is fineReach for a library
A result message under a form✅ — 30 lines, one atomOverkill
One transient confirmation at a time✅ — the region aboveOverkill
A queue with stacking, ordering, dedupereact-hot-toast, sonner
Promise-driven states (loading → success → error)react-hot-toast's toast.promise
Swipe-to-dismiss, pause-on-hover, focus managementsonner, Radix Toast

The libraries earn their keep on the queue, not on the markup. Once two messages can be in flight at once you need ordering, limits, dedupe and exit animations, and that is a real amount of state to own. We have never had two messages in flight, which is why 13 dependencies is still 13. The wider version of that trade — across all 91 components here — is in when hand-rolling actually wins.

Common mistakes

MistakeSymptomFix
{state && <p aria-live>…</p>}Nothing is announced; visually perfectRender the region always, put the text inside it
Toggling role between status and alert on one nodeIntermittent announcementsTwo always-mounted regions, or one polite region
aria-live on a node that is also read in reading orderMessage announced twicearia-hidden the visible copy, or use one node only
display: none on the empty regionRegion leaves the accessibility tree; back to square oneUse sr-only (clipped, still exposed), never hidden
Auto-dismiss with no pause or persistenceMessage gone before it is readPersist it, or meet WCAG 2.2.1 with a pause/extend control
Trusting a Lighthouse 100Score passes, announcement never happensScanners check attribute validity, not mount ordering
A toast for a form's own validation errorUser's attention is dragged away from the fieldInline status next to the control, plus aria-invalid

Frequently asked questions

Does role="status" fix the mounting problem? No. role="status" is shorthand for aria-live="polite" aria-atomic="true", so it inherits the identical requirement: the element has to exist before the text does. Swapping the attribute for the role changes nothing about when React mounts the node.

Why polite and not assertive for an error message? Because the user asked for it. assertive interrupts whatever the screen reader is currently saying, which is right for something arriving unbidden — a session about to expire, a payment that failed in the background. A validation message that appears one beat after the user pressed Submit is expected news, and interrupting to deliver expected news is just noise. Our form errors are all in the second category.

Is an empty always-rendered region bad for SEO or layout? Neither. sr-only clips it to a 1px box and takes it out of flow, so it occupies no space and shifts nothing — no CLS cost. It contributes an empty text node to the DOM, which crawlers ignore. All 344 prerendered routes in this build carry it inside the footer's newsletter form and the page count, sizes and Lighthouse scores did not move.

How do I verify this rather than trust it? Turn on a screen reader — VoiceOver on macOS with Cmd+F5, NVDA on Windows — submit the form, and listen. That is the only reliable test, because the failure is invisible to every static tool. The cheap approximation is to inspect the DOM before the first submit: if the aria-live element is not there, it will not announce.

Templates where this pattern ships

ASoc Lura is a multi-workspace admin whose settings and invite flows are exactly the submit-then-report surfaces this atom exists for. ASoc Pulse leans on real-time panels where the distinction between a persisted status and a transient toast actually bites. ASoc Scholar ships the enrolment and grading forms where a missed error message costs a user real work.

Browse the full sets: React admin templates, Next.js admin templates, Tailwind admin templates. For the form mechanics underneath these messages, read four real forms without React Hook Form and contact forms with Server Actions; for the loading half of the same problem, three loading states and zero shimmer.

Keep reading

Tutorial9 min read

React Tooltip: When 38 aria-label Attributes Beat a Library

38 aria-label attributes, zero tooltip libraries. What this codebase actually uses to name icon-only buttons, and the one place a real tooltip earns its keep.

Read more
Tutorial7 min read

React UI Libraries: When Hand-Rolling 91 Components Actually Wins

This storefront ships zero UI-library dependencies across 91 hand-rolled components. The real tradeoff — bundle cost against accessibility coverage you skip rebuilding — measured against our own code.

Read more