Home/ Cookbook/ States/ Toasts + notifications inbox

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.

States 3 primitives · 0 blocks · 1 pattern ~25 min React · @corelithzw/react

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.

Huchu3

Sales

Today's receipts

Refund processed$48.20 → Tendai M.
Undo
01 Toast slides in over content
Huchu3

Notifications

3 unread · 8 total

Refund processed
$48.20 → Tendai M.
just now
Bank sync failed
FNB · last try 3 min ago
5m
Payroll run finished
14 of 14 paid
1h
Inventory imported
1,204 items
3h
Owner approved leave
Anesu S. · 25–27 Jun
yest
02 Inbox bell — timeline you can revisit
Huchu3
Sale saved#2841 · $124.00
Couldn't syncTap to retry
Retry

Mobile: stack from top, max 3

03 Mobile stack — max 3 visible

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

01

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.

Code-only step

persistent: true = also lands in the inbox.

Never red — design-system tone-danger token only.

Switch to the Code tab to see the snippet.

Step 1 · ToastRecord@corelithzw/react
02

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.

Code-only step

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.

Step 2 · Provider
03

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.

Refund processed
$48.20 → Tendai M.
Undo
Bank sync failed
FNB · we'll retry in 60s
Retry now
Step 3 · ToastRegion
04

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.

Notifications
3 unread
Refund processed
$48.20 → Tendai M.
just now
Bank sync failed
FNB · 3 min ago
5m
Payroll run finished
14 of 14 paid
1h
Step 4 · Inbox bell

Final composition

The whole NotificationsCenter, assembled. Provider + region + bell + a sample async handler that uses both surfaces.

Mukamba Group
3
Refund processed
$48.20 → Tendai M.
Undo
Bank sync failed
FNB · we'll retry in 60s
Retry now
NotificationsCenter.tsx@corelithzw/react

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 uses polite. 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: reduce is set.
  • Touch dismiss works. Swipe-right on mobile dismisses a toast; the same gesture is bound to the Esc key handler so keyboard users get the same outcome.