Skip to content

Toast and Notification Architecture

Own toasts with queueing, live regions, and clear rules for when not to toast , notifications as a product surface, not confetti.

5 min read
Toast
Notifications
Accessibility
Live Regions
React
UX

Forms need a place to put outcomes that are not field errors. Toasts are the usual answer, and the usual mess: five libraries, no queue, focus stolen, and a success toast for "navigated to page."

I treat notifications as a small architecture: ownership, queueing, accessibility, and explicit non-goals.

What a toast is for

Good toast jobs:

  • Confirm a background mutation succeeded ("Invoice sent")
  • Report a non-blocking failure the user can retry ("Sync failed: Retry")
  • Surface a system event while the user stays on the same view

Bad toast jobs:

  • Replacing inline field validation
  • Announcing every route change
  • Carrying long instructions (use a dialog or page)
  • Being the only record of an error that must be fixed before continuing

If the user must act, prefer inline UI or a dialog. Toasts disappear. Disappearing requirements are how support tickets are born.

Ownership

One module owns the queue. Features call notify(), they do not mount their own portal.

ts
// src/notifications/types.ts, illustrative export type Notice = { id: string; tone: 'info' | 'success' | 'danger'; title: string; description?: string; durationMs?: number; // undefined = sticky until dismiss action?: { label: string; onClick: () => void }; }; export type NotifyInput = Omit<Notice, 'id'> & { id?: string };
ts
// src/notifications/store.ts, illustrative tiny store type Listener = (notices: Notice[]) => void; let notices: Notice[] = []; const listeners = new Set<Listener>(); function emit() { for (const l of listeners) l(notices); } export function subscribe(listener: Listener) { listeners.add(listener); listener(notices); return () => listeners.delete(listener); } export function notify(input: NotifyInput) { const id = input.id ?? crypto.randomUUID(); notices = [...notices.filter((n) => n.id !== id), { ...input, id }]; emit(); return id; } export function dismiss(id: string) { notices = notices.filter((n) => n.id !== id); emit(); }

This can be Zustand, context, or a framework toast kit, the rule is one queue, not the implementation logo.

Queueing rules

Without rules, overlapping mutations spam the corner.

Rules I use:

  1. Dedupe by id for the same logical event (invoice-sent-${id})
  2. Cap visible toasts (e.g. 3); overflow waits or replaces oldest info
  3. Danger stays longer than success (or sticky with dismiss)
  4. Success auto-dismiss; errors often need a manual close or action
  5. Never stack five successes for one bulk action, one summary toast
ts
// features/invoices/sendInvoice.ts, illustrative call site import { notify } from '@/notifications/store'; export async function sendInvoice(id: string) { const res = await fetch(`/api/invoices/${id}/send`, { method: 'POST' }); if (!res.ok) { notify({ id: `invoice-send-fail-${id}`, tone: 'danger', title: 'Could not send invoice', description: 'Check the recipient email and try again.', durationMs: undefined, }); throw new Error('send failed'); } notify({ id: `invoice-send-ok-${id}`, tone: 'success', title: 'Invoice sent', durationMs: 4000, }); }

Accessibility: live regions, not focus theft

Toasts should announce, not steal focus (unless the toast contains a critical action that requires it, rare).

tsx
// src/notifications/Toaster.tsx, illustrative live region import { useEffect, useState } from 'react'; import { dismiss, subscribe, type Notice } from './store'; export function Toaster() { const [items, setItems] = useState<Notice[]>([]); useEffect(() => subscribe(setItems), []); useEffect(() => { const timers = items .filter((n) => n.durationMs != null) .map((n) => window.setTimeout(() => dismiss(n.id), n.durationMs as number), ); return () => timers.forEach(clearTimeout); }, [items]); return ( <div className="toaster" aria-live="polite" aria-relevant="additions text" aria-atomic="false" > {items.map((n) => ( <div key={n.id} className="toast" data-tone={n.tone} role={n.tone === 'danger' ? 'alert' : 'status'} > <p>{n.title}</p> {n.description ? <p>{n.description}</p> : null} <button type="button" onClick={() => dismiss(n.id)} > Dismiss </button> </div> ))} </div> ); }

Notes:

  • aria-live="polite" for most success/info; role="alert" for urgent errors
  • Do not move focus into the toast on every save
  • Honor prefers-reduced-motion for slide animations
  • Ensure dismiss controls are keyboard reachable when the toast is sticky

When not to toast

SituationPrefer
Field-level validationInline error + focus
Destructive confirmDialog
Full-page failureError boundary / route error UI
Long-running jobDedicated status panel / progress
User already sees the new dataSilent success or subtle inline confirm

Silent success is underrated. If the list updates and the row appears, maybe you do not need fireworks.

Failure modes

Toast-as-logger. Developers notify on every console.warn path. Users learn to ignore the corner, then miss the one danger toast that mattered.

Focus stolen into the toast. Saving a form jumps assistive tech into a temporary region; the user's place in the form is gone. Announce politely; keep focus where the work is.

No dismiss for sticky errors. An error toast without a close control (and without an action) becomes inaccessible wallpaper.

Duplicate libraries. Feature A uses sonner, feature B uses a home-grown portal, feature C uses the component kit's snackbar. Pick one owner module.

I will not invent "N% fewer support tickets" from toast discipline. The review signal is a single queue and a written when-not-to-toast table the team can argue with.

Continuity

Forms produce outcomes; toasts route the non-blocking ones. Error boundaries cover when the failure is not a toast but a render crash, and how granular recovery should be.

Takeaway

One notification queue, live-region announcements, and the discipline to skip toasts when inline UI or silence is clearer, notifications are product UX, not confetti.