How would you build a notification system (toasts/alerts) with queuing and auto-dismiss behavior in React
Toast manager: context/store of active toasts, a `<ToastContainer/>` portal that renders them stacked, imperative API (`toast.success(msg)`) backed by the store, per-toast options (variant, duration, dismissible, action), auto-dismiss timers, queueing if max-visible exceeded, swipe-to-dismiss, focus management, ARIA live region for accessibility.
A toast system has small visible surface and a lot of detail underneath: queueing, timing, focus, accessibility, animations. Build it as a manager (store + imperative API) with a container (renders).
1. The API
toast("Saved");
toast.success("Order placed");
toast.error("Could not save", { duration: 0 }); // sticky
toast.promise(api.save(), {
loading: "Saving...",
success: "Saved",
error: "Failed",
});
toast.dismiss(id);2. The store
class ToastStore {
constructor() {
this.toasts = [];
this.subs = new Set();
}
add({ id = crypto.randomUUID(), variant = "info", message, duration = 4000, action } = {}) {
const t = { id, variant, message, duration, action, createdAt: Date.now() };
this.toasts = [...this.toasts, t];
this._notify();
if (duration > 0) setTimeout(() => this.dismiss(id), duration);
return id;
}
dismiss(id) {
this.toasts = this.toasts.filter((t) => t.id !== id);
this._notify();
}
subscribe(fn) { this.subs.add(fn); return () => this.subs.delete(fn); }
_notify() { for (const fn of this.subs) fn(this.toasts); }
}
const store = new ToastStore();
export const toast = (message, options) => store.add({ message, ...options });
toast.success = (msg, opts) => toast(msg, { ...opts, variant: "success" });
toast.error = (msg, opts) => toast(msg, { ...opts, variant: "error", duration: 0 });
toast.promise = (promise, msgs) => {
const id = toast(msgs.loading, { duration: 0 });
promise
.then(() => { store.dismiss(id); toast.success(msgs.success); })
.catch(() => { store.dismiss(id); toast.error(msgs.error); });
return promise;
};
toast.dismiss = (id) => store.dismiss(id);3. The container
function ToastContainer({ position = "top-right", maxVisible = 5 }) {
const [toasts, setToasts] = useState(store.toasts);
useEffect(() => store.subscribe(setToasts), []);
const visible = toasts.slice(0, maxVisible);
const queued = toasts.length - visible.length;
return createPortal(
<div className={`toast-stack toast-${position}`} role="region" aria-label="Notifications">
<ol aria-live="polite" aria-relevant="additions">
{visible.map((t) => <ToastItem key={t.id} toast={t} onDismiss={() => toast.dismiss(t.id)} />)}
</ol>
{queued > 0 && <div className="toast-queued">+{queued} more</div>}
</div>,
document.body
);
}4. The toast item
function ToastItem({ toast: t, onDismiss }) {
return (
<li
className={`toast toast-${t.variant}`}
role={t.variant === "error" ? "alert" : "status"}
>
<Icon variant={t.variant} aria-hidden />
<span>{t.message}</span>
{t.action && <button onClick={t.action.onClick}>{t.action.label}</button>}
<button aria-label="Dismiss" onClick={onDismiss}>×</button>
</li>
);
}5. Auto-dismiss
A setTimeout per toast. Cancel on hover to give the user time to read:
useEffect(() => {
if (t.duration <= 0 || paused) return;
const id = setTimeout(onDismiss, remaining);
return () => clearTimeout(id);
}, [t.duration, paused, remaining, onDismiss]);Track remaining = duration - elapsed so pausing on hover and resuming on leave continues where it left off.
6. Queueing
If toasts.length > maxVisible, only the first N show. As one is dismissed, the next surfaces. Show a "+3 more" indicator.
7. Animations
- Slide/fade in on add, out on dismiss.
- Use Framer Motion's
<AnimatePresence>or CSS transitions. - Respect
prefers-reduced-motion.
8. Swipe-to-dismiss
For mobile: pointerdown → track translateX → above threshold → dismiss.
9. Accessibility — the part most libraries get wrong
- Toast container wrapped in
role="region"witharia-label="Notifications". - Live region:
aria-live="polite"for status,role="alert"(which is implicitlyaria-live="assertive") for errors. - Don't auto-focus the toast — it disrupts the user.
- Focus management: when a toast has an action button, ensure keyboard users can reach it (Tab into the live region) — but don't auto-focus.
- Sufficient duration — minimum 5 seconds for non-trivial messages so users have time to read.
- Dismiss buttons with accessible names.
- Persistent errors that require action shouldn't auto-dismiss.
10. Don't overdo it
Toasts should be non-blocking, non-critical. Errors that demand action belong in a modal or inline, not a toast that disappears.
Real-world libraries
react-hot-toast, sonner, react-toastify — all solve this. Recommend adopting unless your design system requires custom.
Interview framing
"A central store of active toasts plus a portal-rendered container. The imperative API (toast.success(msg)) is a thin wrapper over store.add. Each toast has variant, message, duration, optional action. Auto-dismiss via setTimeout (with pause-on-hover by tracking remaining time). Render at most N visible, queue the rest with a 'more' indicator. Animate in/out — Framer Motion or CSS — with reduced-motion respect. Accessibility is the part most homegrown systems get wrong: live region with aria-live='polite' for status, role='alert' for errors, don't auto-focus the toast, persistent errors shouldn't auto-dismiss. And in real life, adopt react-hot-toast or sonner unless the design system requires custom."
Follow-up questions
- •How do you pause auto-dismiss on hover?
- •Why use aria-live for the container?
- •How do you handle a toast with a required action?
- •When is a toast the wrong UX choice?
Common mistakes
- •Auto-focusing the toast — disrupts user.
- •Auto-dismissing errors that need action.
- •No queue — many toasts overlap.
- •Missing live region — screen readers don't hear.
- •No reduced-motion respect.
Performance considerations
- •Few toasts; perf rarely matters. Don't re-create the store across renders.
Edge cases
- •Toast dispatched during route change.
- •Many rapid-fire toasts (rate-limit on the store).
- •Promise toast where promise resolves before mount.
- •Mobile swipe vs tap-to-dismiss.
Real-world examples
- •react-hot-toast, sonner, react-toastify.
- •Stripe / Linear / Vercel toast UX.