Toast Notifications Are Broken: A Better Pattern for Async Feedback
Toasts fail screen readers, disappear before users can read them, and stack into unreadable towers. Here's the async feedback pattern we ship instead.

Toasts are the design system equivalent of a sticky note slapped on a monitor: quick, convenient, and quietly failing half the people who need to read them. They vanish before slow readers finish, they stack into unreadable towers during bulk actions, and their accessibility story is a shrug wrapped in an aria-live region.
We've rebuilt notification systems for three products in the last year. Every time, the fix wasn't a prettier toast — it was admitting that "toast" is actually four different jobs pretending to be one.
The four jobs we're asking a toast to do
When you look at where teams reach for a toast, it's rarely one pattern. It's a mash-up:
- Confirmation — "Draft saved." User did a thing, thing worked, move on.
- Async result — "Export ready" arriving 40 seconds after the click.
- Error recovery — "Payment failed. Retry?" requires a decision.
- System status — "You're offline" until the network returns.
Each has different timing, different persistence, different accessibility needs, and different consequences for missing it. Cramming all four into a 3-second slide-in from the bottom-right is why users email support saying "nothing happened" after an action that clearly did.
The disappearing act problem
The WCAG 2.2 guideline on time limits (2.2.1) isn't ambiguous: if content disappears on a timer, users need a way to extend, dismiss on their terms, or otherwise not be at the mercy of a countdown they didn't ask for. A toast that auto-dismisses after 4 seconds fails screen reader users, users with cognitive disabilities, users who just glanced away, and honestly most users during a busy workflow.
The common workaround — "we'll make errors persistent, successes ephemeral" — is better, but still treats persistence as a styling decision rather than a semantic one.
What we ship instead: a tiered feedback model
Our current pattern separates feedback by where the user's attention should go, not by severity color. Four tiers:
- Inline — feedback attached to the element that caused it
- Ambient — non-blocking status, persistent, low visual weight
- Transient — the actual toast, used sparingly
- Blocking — modal or interruption, used almost never
The decision tree we hand to engineers looks like this:
type FeedbackTier = 'inline' | 'ambient' | 'transient' | 'blocking';
function pickTier(event: {
requiresDecision: boolean;
originatedFromElement: boolean;
arrivesAsync: boolean;
blocksProgress: boolean;
}): FeedbackTier {
if (event.blocksProgress) return 'blocking';
if (event.requiresDecision) return 'ambient';
if (event.originatedFromElement && !event.arrivesAsync) return 'inline';
return 'transient';
}
That's it. No severity, no color. The tier is a function of user context, not message tone.
Inline: the underrated default
A save button that turns into a checkmark for 800ms, then back. A form field that shows its own error below itself. A row that briefly highlights green when updated.
Inline feedback is the most accessible option available — the user is already looking at the element, and the change is co-located with the cause. We've cut toast usage by roughly 60% on recent projects just by pushing confirmation feedback back to its source.
The rule we use: if the user's cursor or focus is already on the trigger, the feedback belongs on the trigger.
The ambient tier: where async actually lives
This is the tier most teams don't have, and it's where the interesting UX work happens. Ambient feedback is a persistent, low-attention region — usually a status bar, a notification tray, or a dedicated "activity" panel.
When a user clicks "Export 12,000 rows to CSV," the immediate feedback is inline ("Export started"). The result — 40 seconds later — arrives in the ambient tray, not as a toast that might disappear while they're in another tab.
// Simplified activity tray hook
const { addActivity, updateActivity } = useActivityTray();
async function handleExport() {
const id = addActivity({
label: 'Exporting 12,000 rows',
state: 'running',
startedAt: Date.now(),
});
try {
const url = await exportRows();
updateActivity(id, {
state: 'complete',
action: { label: 'Download', href: url },
});
} catch (err) {
updateActivity(id, {
state: 'failed',
action: { label: 'Retry', onClick: handleExport },
});
}
}
A few things to notice:
- The activity persists until the user dismisses it or completes the follow-up action
- Retry is attached to the activity, not to a disappearing toast
- Multiple concurrent exports don't stack — they list, cleanly, in one place
- Screen reader announcements can be tied to state changes with a polite
aria-liveregion on the tray itself
The aria-live gotcha nobody warns you about
If you're using aria-live="polite" on a container, mutating the DOM inside it doesn't always announce. Screen readers need the region to exist in the DOM at load time, and they need the text content to change — not just appear inside a newly inserted child.
What works reliably across NVDA, JAWS, and VoiceOver in our testing:
<!-- Static container, present on page load -->
<div
role="status"
aria-live="polite"
aria-atomic="true"
class="sr-only"
id="activity-announcer"
></div>
Then, when an activity changes state, we update the textContent of that node directly with a short announcement ("Export complete. 12,000 rows."). The visible tray updates independently. Announcement and UI are decoupled, and both work.
Don't announce every state change. "Started" is noise. Announce completion, failure, and anything requiring a decision.
Transient toasts: what they're actually for
After this restructuring, the transient tier — the actual slide-in-and-disappear pattern — has one legitimate use: confirming a reversible action taken far from the current viewport.
The canonical example is "Archived. Undo" in an email client. The user clicked archive, the item vanished from view, and they need a 5-second window to reverse it. That's a toast. It's transient because the undo window is genuinely time-limited by product design.
Even here, we add two rules:
- Minimum display time of 6 seconds (not the 3 that most libraries default to)
- Pause the timer on hover and on keyboard focus within the toast
- A duplicate entry lands in the ambient tray with a longer undo window
The last point matters. If the toast disappears before the user notices, the action is still recoverable from the tray. Toast becomes a convenience layer over a durable system, not the system itself.
Stacking: the anti-pattern to kill
Bulk actions expose the stacking failure immediately. Select 30 rows, delete them, and a naive toast system produces 30 identical toasts sliding up the corner like a stock ticker.
The fix isn't "limit to 3 toasts." The fix is aggregating at the source: one activity entry that says "30 items deleted. Undo" with a single undo button. If your notification component is doing the aggregation, it's too late — the wrong layer is making the decision.
Blocking: use it, but almost never
Modal interruption is appropriate for exactly two cases: data loss risk ("You have unsaved changes") and security decisions ("Confirm this payment"). Everything else that reaches for a modal — including most error states — should demote to ambient with a clear action attached.
A rejected API call in the middle of a form shouldn't be a modal. It should be inline error text next to the field that broke, or an ambient banner if the whole form is unusable.
Where we'd start
If you're staring at a codebase that leans on toast.success() for everything, don't rewrite it in a sprint. Do this instead:
- Audit one week of toasts. Grep every
toast(call. Sort by tier using the decision tree above. You'll usually find 40–60% should be inline. - Build the ambient tray first. It's the missing tier and it unblocks the async story immediately. Even a minimal tray beats a good toast for anything that takes longer than about two seconds.
- Move async results out of toasts. Every long-running action moves to the tray. Immediate confirmations move inline. What's left in the toast layer should feel small.
- Fix the announcer. One static
role="status"region, text-content updates, announce only meaningful state transitions. - Set a minimum toast duration. 6 seconds, pause on hover and focus. Non-negotiable.
The goal isn't to eliminate toasts. It's to stop asking one component to do four jobs badly. Once feedback is tiered by user context, the whole notification layer gets quieter, more accessible, and — the part that surprises stakeholders — noticeably calmer to use. If you want a hand auditing your current pattern, our product engineering team does this kind of design-system-to-implementation work often.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

Modals Are a Routing Problem: Rethinking Dialogs in 2026
Most modal bugs aren't CSS bugs. They're routing bugs, focus bugs, and state bugs pretending to be design decisions. Here's how to actually ship dialogs that behave.

Form Field Errors: Why Yours Are Failing Users and How to Fix Them
Inline validation looks helpful on the Figma frame and hostile in production. Here's how to design error states that guide users through a form instead of punishing them for typing.

Design Tokens That Survive Contact With Engineering
Most design token systems die somewhere between Figma and production. Here's how we structure tokens so they actually round-trip between designers and engineers without becoming a second source of truth nobody trusts.
