Back to Machine Coding
Machine Coding
medium
mid

How would you 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.

7 min read·~35 min to think through

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.

ts
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.

tsx
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" (or role="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

tsx
const timers = useRef(new Map<string, number>());
function arm(t: Toast) {
  if (t.duration <= 0) return;
  const id = window.setTimeout(() => toast.dismiss(t.id), t.duration);
  timers.current.set(t.id, id);
}
function disarm(id: string) {
  const tid = timers.current.get(id);
  if (tid) { clearTimeout(tid); timers.current.delete(id); }
}
// onMouseEnter / onFocus → disarm(t.id); onMouseLeave / onBlur → arm(t)
Pause-on-hover with per-toast timer cleanup

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.

Senior engineer discussion

Senior signal: store-based architecture for the imperative API, ARIA distinctions per type, and pause-on-hover. Bonus: promise-based toast() helper.

Related questions