Build a toast system and a notifications inbox that share one queue
By the end of this recipe you'll have a single useToast hook for transient confirmations, a stacking top-right-on-desktop / top-on-mobile region, an action-button slot, and an inbox bell with badge that surfaces every notification worth revisiting later.
Overview
Two surfaces, one data source. Toasts are transient — they slide in, sit for a few seconds, slide out. The inbox is a persistent timeline of everything the user might want to revisit.
Most apps confuse the two: a "Saved!" confirmation gets added to a notifications inbox the user has to clean up, or a failed background job pops a toast that disappears before the user reads it. The split here is hard: toasts are for right now; the inbox is for later. The system decides which channel each event goes to.
Errors get toasts. Successes might not. A successful save the user just triggered doesn't need its own slide-in — the row updated, that's the confirmation. Reserve toasts for things the user couldn't see otherwise: failures, background completions, async results.
Toasts use the design-system tone-danger token for failures. Never red — red is a theme decision; tone-danger is a semantic one, and a brand re-skin updates both toasts and inline errors in lockstep.
Skip this recipe if your app has no async work and no background events. A purely synchronous form doesn't need toasts; the inline error message under each field is enough.
What you'll build
A toast on desktop, the same event in the inbox bell, and the mobile-stacked variant.
Sales
Today's receipts
Notifications
3 unread · 8 total
$48.20 → Tendai M.
FNB · last try 3 min ago
14 of 14 paid
1,204 items
Anesu S. · 25–27 Jun
Mobile: stack from top, max 3
Required pieces
Everything this recipe pulls from @corelithzw/react.
@corelithzw/react exports used here: Toast, ToastProvider, useToast, Stack, Button. The provider owns the queue; useToast dispatches into it.
Step-by-step build
Model a toast as a record with a destiny
Every toast has a persistent flag — does this event also belong in the inbox? Successes that are reversible (undo) belong in the inbox; an autosave confirmation doesn't. The provider routes accordingly.
persistent: true = also lands in the inbox.
Never red — design-system tone-danger token only.
Switch to the Code tab to see the snippet.
Build the queue + provider — one source of truth
The queue is a single array in a provider context. Components dispatch via useToast; the provider owns dismissal, auto-expiry timers, and writes persistent toasts into the inbox. Two surfaces, one queue.
Auto-dismiss timers live in a single useEffect.
Inbox is capped at 200 entries; old ones fall off.
Switch to the Code tab to see the snippet.
Render the toast region — top-right desktop, top mobile
The toast region is fixed-position and renders the visible queue. On desktop it lives at top-right; on mobile it slides from the top, centred. Stacking is capped at three visible — older ones queue up behind, not above.
Render the bell + inbox — same data, different surface
The bell shows the count of unread inbox items; clicking opens a popover with the full timeline. Clicking an entry marks it read. The inbox is purely visual on the client — no special API; it's just the persistent slice of the queue.
Final composition
The whole NotificationsCenter, assembled. Provider + region + bell + a sample async handler that uses both surfaces.
Variations
Three common forks. Each is a small diff from the final composition.
Inbox-only (no toast)
For background events the user wasn't watching for. Push with silent: true — the inbox catches it but no toast pops.
// Add a `silent` flag to ToastRecord
case 'PUSH': {
const inbox = a.toast.persistent
? [a.toast, ...s.inbox].slice(0, 200) : s.inbox;
const queue = a.toast.silent
? s.queue : [...s.queue, a.toast];
return { queue, inbox };
}
Server-pushed notifications
WebSocket pushes from the server. Map the incoming message shape into a ToastRecord and call push.
useEffect(() => {
const ws = new WebSocket(WS_URL);
ws.onmessage = (m) => {
const e = JSON.parse(m.data);
push({
tone: e.severity, title: e.title,
description: e.body, persistent: true,
});
};
}, [push]);
Grouped consecutive toasts
Three "Item saved" toasts within 5 seconds collapse into one "3 items saved". Track group keys.
// Each toast can carry a `groupKey`
const last = s.queue[s.queue.length - 1];
if (last?.groupKey === a.toast.groupKey) {
last.count = (last.count ?? 1) + 1;
return { ...s };
}
Accessibility
What this recipe takes care of for you.
- Toasts announce themselves. Danger toasts use
aria-live="assertive"(interrupt), everything else usespolite. Errors should not be missed. - The region is named.
role="region"+aria-label="Notifications"on the toast container so screen-reader users can jump to it via landmark navigation. - Action buttons are real buttons. "Undo" / "Retry" are
<Button>elements inside the toast, not clickable text. Tab from the toast lands on the action. - Dismiss has a label. The X button on each toast carries
aria-label="Dismiss". Mouse and keyboard users have parity. - The bell announces unread count.
aria-label="Notifications, 3 unread"updates as the inbox changes — screen readers re-announce on focus. - Auto-dismiss respects motion preferences. Slide animation is replaced with an instant fade when
prefers-reduced-motion: reduceis set. - Touch dismiss works. Swipe-right on mobile dismisses a toast; the same gesture is bound to the
Esckey handler so keyboard users get the same outcome.