Build a toast notification system with queueing and auto-dismiss
Imperative API (toast.success/error/...) backed by a global store. Render via Portal with aria-live='polite'. Auto-dismiss with pause-on-hover. Cap visible count and queue overflow.
Toasts feel simple but combine an imperative API, a global store, accessibility, animation, and queue management. The right architecture is store + headless API + portal renderer.
1. Imperative API. toast.success("Saved"), toast.error("Failed", { duration: 5000 }). Returns an id you can use to dismiss programmatically. This API can be called from anywhere — utilities, mutations, error handlers — without prop drilling.
2. Backed by a global store. A tiny pub-sub (or Zustand store). The Toaster component subscribes and re-renders when toasts change.
type Toast = { id: string; message: string; type: "success" | "error" | "info"; duration: number };
const listeners = new Set<(toasts: Toast[]) => void>();
let toasts: Toast[] = [];
const emit = () => listeners.forEach(l => l(toasts));
export const toast = {
show(message: string, opts: Partial<Toast> = {}) {
const id = crypto.randomUUID();
const t: Toast = { id, message, type: opts.type ?? "info", duration: opts.duration ?? 4000 };
toasts = [...toasts, t];
emit();
if (t.duration > 0) setTimeout(() => toast.dismiss(id), t.duration);
return id;
},
success: (m: string, o?: Partial<Toast>) => toast.show(m, { ...o, type: "success" }),
error: (m: string, o?: Partial<Toast>) => toast.show(m, { ...o, type: "error" }),
dismiss(id: string) { toasts = toasts.filter(t => t.id !== id); emit(); },
subscribe(cb: (toasts: Toast[]) => void) { listeners.add(cb); cb(toasts); return () => listeners.delete(cb); },
};3. Renderer (<Toaster />). One instance mounted at the app root. Subscribes to the store, renders via Portal. Stacked vertically with enter/exit animations.
export function Toaster() {
const [items, setItems] = useState<Toast[]>([]);
useEffect(() => toast.subscribe(setItems), []);
return createPortal(
<div className="fixed bottom-4 right-4 flex flex-col gap-2" aria-live="polite" aria-atomic="false">
<AnimatePresence>
{items.slice(-5).map(t => (
<motion.div key={t.id} layout
initial={{ opacity: 0, y: 16 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, x: 100 }}
role={t.type === "error" ? "alert" : "status"}
className={`rounded shadow px-4 py-2 ${t.type === "error" ? "bg-red-600 text-white" : "bg-white"}`}>
{t.message}
<button onClick={() => toast.dismiss(t.id)} aria-label="Dismiss">×</button>
</motion.div>
))}
</AnimatePresence>
</div>,
document.body
);
}4. Accessibility — the part most teams miss.
- Container:
aria-live="polite"for info/success,aria-live="assertive"(orrole="alert"per-toast) for errors. aria-atomic="false"so additions are announced individually, not the whole region.- Errors should be
role="alert"so screen readers interrupt. - Always include a dismiss button for keyboard users; never rely solely on auto-dismiss.
5. Pause-on-hover / focus. Auto-dismiss is hostile if the user is reading. On onMouseEnter / onFocus, clear the timer; on leave, restart it (or replace with a fresh duration). Track per-toast timer ids in a ref-map.
6. Queue / overflow. Cap visible to ~5. Overflow either: (a) drop oldest, (b) queue and show the next when one dismisses. Most apps drop oldest — silence is better than a wall of toasts.
7. Position. Bottom-right is the usual default. Top-center for confirmation flows. Avoid covering CTA buttons.
8. Don't roll your own in production. react-hot-toast, sonner, or @radix-ui/react-toast (a11y-correct) are all great. Build your own only when you need an unusual API or visual.
Code
Follow-up questions
- •How would you support a 'undo' action on a toast?
- •How would you handle promise-based toasts (loading → resolved/rejected)?
- •Why aria-live='polite' for success but 'assertive' for errors?
- •How would you persist toasts across route changes?
Common mistakes
- •No pause-on-hover — user can't read long messages.
- •Missing role='alert' on errors — screen readers don't announce.
- •Storing toasts in component state — every component that calls toast() needs a context.
- •Forgetting to clear timers on dismiss — memory leak under high churn.
Performance considerations
- •Use a flat global store, not React context — avoids re-rendering the entire tree.
- •Animate transform/opacity (composited), not top/height.
Edge cases
- •Same toast triggered N times in a row — dedupe by message + type.
- •Browser tab backgrounded → setTimeout throttles; auto-dismiss stretches. Acceptable.
- •Toast triggered during SSR → no-op until hydration mounts the Toaster.
Real-world examples
- •Vercel's deploy notifications (sonner), GitHub's save indicators, Linear's command results.