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.
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:
| Property | Toast | Inline status (what we use) |
|---|---|---|
| Position | Fixed overlay, detached from the trigger | In flow, directly under the control that caused it |
| Lifetime | Auto-dismisses after N seconds | Persists until the next submit |
| Focus relationship | None — the user may be anywhere on the page | Adjacent to the element the user just left |
| Announcement | Needs a live region, always | Needs a live region, always |
| Failure mode | Message disappears before it is read | Message 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 need | Hand-rolled is fine | Reach for a library |
|---|---|---|
| A result message under a form | ✅ — 30 lines, one atom | Overkill |
| One transient confirmation at a time | ✅ — the region above | Overkill |
| A queue with stacking, ordering, dedupe | ❌ | react-hot-toast, sonner |
| Promise-driven states (loading → success → error) | ❌ | react-hot-toast's toast.promise |
| Swipe-to-dismiss, pause-on-hover, focus management | ❌ | sonner, 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
| Mistake | Symptom | Fix |
|---|---|---|
{state && <p aria-live>…</p>} | Nothing is announced; visually perfect | Render the region always, put the text inside it |
Toggling role between status and alert on one node | Intermittent announcements | Two always-mounted regions, or one polite region |
aria-live on a node that is also read in reading order | Message announced twice | aria-hidden the visible copy, or use one node only |
display: none on the empty region | Region leaves the accessibility tree; back to square one | Use sr-only (clipped, still exposed), never hidden |
| Auto-dismiss with no pause or persistence | Message gone before it is read | Persist it, or meet WCAG 2.2.1 with a pause/extend control |
| Trusting a Lighthouse 100 | Score passes, announcement never happens | Scanners check attribute validity, not mount ordering |
| A toast for a form's own validation error | User's attention is dragged away from the field | Inline 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.
