Most toast components pass a quick visual review and fail every screen reader test, because the accessibility layer usually gets bolted on after the animation and styling are already done. Here's a walkthrough of building one the right way from the start, using plain JavaScript so the pattern transfers to any framework.
The order matters more than it might seem. Building the live region and message-category logic first, before the animation and visual polish, forces the accessible behavior to be a first-class part of the component rather than something wedged in afterward once the "real" work is done. It's also just less total effort: retrofitting accessibility into a finished component usually means restructuring the DOM anyway, so you end up doing the same work twice.

Photo by Wolf Art on Pexels
Step 1: Create the Live Region Container Once
Rather than injecting a new ARIA live region for every toast, create a single persistent container when the app loads and reuse it for every message. Spawning a fresh live region per toast is a common source of missed announcements in screen readers.
<div id="toast-region" aria-live="polite" aria-atomic="true" class="toast-region"></div>
Reserve a second container with aria-live="assertive" specifically for errors and warnings, since interrupting the user's screen reader is appropriate there and inappropriate everywhere else. MDN's ARIA documentation covers the difference in more depth if you haven't worked with live regions before.
<div id="toast-region-assertive" aria-live="assertive" aria-atomic="true" class="toast-region"></div>
Both containers can be visually hidden and positioned wherever your design calls for (a corner, a bottom bar, wherever), since their position in the DOM has no bearing on how they're announced. What matters for the announcement is the aria-live attribute itself and the fact that the container already existed on the page before its contents changed.
Step 2: Separate Message Data From Rendering
Keep a small in-memory queue of message objects ({id, category, text, duration}) separate from the DOM nodes that render them. This makes it trivial to enforce rules like "cap visible toasts at three" or "never let a warning get pushed off-screen by a newer confirmation" without tangling that logic into rendering code.
function pushToast({ category, text }) {
const duration = category === "error" ? null : category === "warning" ? null : 4000;
queue.push({ id: crypto.randomUUID(), category, text, duration });
render();
}
Setting duration: null for warnings and errors is the mechanism that enforces "never auto-dismiss" from the design rules. It's a one-line decision in code, but it's the line most homegrown toast libraries skip.
This separation also makes it straightforward to add a persistent notification log later, since the queue array is already the single source of truth for every message that's fired. Rather than a separate feature, a history dropdown becomes a small UI that reads from the same array and simply doesn't prune entries the way the visible toast rendering does.
Step 3: Render Into the Correct Live Region
When rendering, route confirmations and info messages into the polite region and everything severity-critical into the assertive one. This keeps the announcement behavior matched to the actual urgency of the message rather than treating every toast identically.
function render() {
queue.slice(0, 3).forEach(item => {
const region = document.getElementById(
item.category === "error" || item.category === "warning"
? "toast-region-assertive"
: "toast-region"
);
const node = document.createElement("div");
node.setAttribute("role", item.category === "error" ? "alert" : "status");
node.textContent = item.text;
region.appendChild(node);
if (item.duration) setTimeout(() => node.remove(), item.duration);
});
}
If you're working inside a framework rather than plain DOM APIs, the same principles apply, only the mechanics of "create once, update contents" change. In React, that usually means the live region container lives in a component near the root of the tree and receives updates via context or a small state store rather than being mounted and unmounted per toast. In Vue or Svelte, a similar singleton pattern applies. What doesn't change across frameworks is the underlying rule: one persistent container per severity tier, contents updated in place.
Step 4: Give Every Dismiss Control a Real Label
If your toast includes a close button, make sure it has an accessible name that describes what it dismisses, not just what it looks like.
<button aria-label="Dismiss notification: upload failed">×</button>
An unlabeled "X" glyph is one of the most common failures caught in accessibility audits, and it takes seconds to fix once you know to look for it.
Step 5: Respect Reduced Motion
Wrap your slide or fade animation in a media query check so users with prefers-reduced-motion: reduce get an instant appearance instead. WebAIM and Nielsen Norman Group both have write-ups on why motion sensitivity matters for a meaningful share of users, not just an edge case.
Step 6: Cap Visible Toasts and Respect Severity in the Queue
Rendering everything in the queue at once defeats the point of having a queue. Limit visible toasts to two or three, and make sure severity, not arrival time, decides which ones stay visible when the cap is hit.
function prioritize(queue) {
const order = { error: 0, warning: 1, info: 2, confirmation: 3 };
return [...queue].sort((a, b) => order[a.category] - order[b.category]);
}
Without this sort, a newer confirmation can visually bump an older warning out of the visible slots, which inverts the priority the whole system is supposed to enforce.
Step 7: Add a Lightweight Notification History
Since the queue array already tracks every message that's fired, exposing a simple history view is cheap to add once the core system exists.
function getHistory(limit = 10) {
return allFiredMessages.slice(-limit).reverse();
}
A small dropdown or panel that renders this array gives users a way to check what a toast said after it's already disappeared, which closes a real usability gap that most notification systems never address. It also gives your support team a much easier bug report to work with than "I saw some kind of error."
Step 8: Handle the Case Where JavaScript Hasn't Loaded Yet
For anything critical, like a form submission error, don't rely solely on the toast component for the user's only signal that something failed. If the client-side JavaScript that powers the toast hasn't finished loading, or fails to load at all on a flaky connection, a critical error can silently disappear. Server-rendered fallback messaging, or at minimum a non-JavaScript-dependent visual indicator on the affected field, is a reasonable safety net for the small percentage of sessions where the client-side notification system itself is the thing that's broken.
Step 9: Document the Category Rules Somewhere the Whole Team Can Find
The categorization logic in Step 2, which categories auto-dismiss, which don't, what the timing values are, tends to live only in the component's source code unless someone deliberately writes it down elsewhere. A short design-system document, even a single page, that states these rules in plain language saves a lot of future debate when a new feature needs a notification and the engineer building it isn't sure which category their message belongs to.
Testing the Result
Turn on a screen reader (VoiceOver, NVDA, or the built-in one on your OS) and trigger a few toasts in sequence: a confirmation, an error, and two toasts fired close together. If you can't tell what happened without looking at the screen, something in steps 1 through 5 needs revisiting.
Test the reduced-motion path too, not just the default one. It's easy for a team to check the animated version repeatedly during development and never once toggle the OS-level reduced motion setting, which means that code path can go untested for months even though it's live in production the whole time.
137Foundry's frontend engineering team wrote a longer piece on the design decisions behind this pattern, covering timing rules, stacking behavior, and mobile-specific adjustments, in our full guide to notification system design.
Top comments (0)