Building a Robust React Toast Notification System: Patterns, Code, and UX
Design and implement a fast, accessible React toast system with clean APIs, animations, and promise toasts—complete code, patterns, and pitfalls.
Image used for representation purposes only.
Overview
Toast notifications are brief, unobtrusive UI messages that confirm actions, surface errors, or provide background status without blocking the user. In React, a great toast system is fast to trigger, accessible by default, easy to theme, and simple to test. This guide walks through UX principles, architectural patterns, a production‑ready implementation (with code), and advanced features like promise toasts and queueing.
UX principles that make toasts work
A polished toast system is more about restraint than fireworks.
- Keep it brief: 2–4 seconds for success/info; 6–8 for warnings; persistent or manual‑dismiss for critical errors.
- Don’t stack forever: cap visible toasts (e.g., 3) and queue the rest.
- Prioritize meaning: concise title + optional one‑line description; avoid paragraphs.
- Offer an action when it’s useful: e.g., Undo, Retry; keep it single and high‑value.
- Respect accessibility: appropriate live regions, focus management, and keyboard controls.
- Maintain consistency: placement (top‑right is common), motion, colors, and icons.
Buy vs. build
- Use a library when time is tight or you need battle‑tested polish. Popular options ship theming, animations, and a tiny API surface.
- Build your own when you need strict design control, minimal bundle size, or deep integration with app state. The following implementation aims for that sweet spot: small, accessible, and extensible.
Core architecture
A reliable pattern uses these pieces:
- Central store: holds a list of toasts with metadata (id, type, message, duration, createdAt).
- Provider + Context: exposes imperative helpers like toast.success, toast.error, toast.promise.
- Portal viewport: renders toasts above the app (e.g., body) with consistent z-index.
- Timer management: per‑toast auto‑dismiss with pause on hover.
- Accessibility: ARIA live regions, keyboard navigation, and reduced‑motion support.
Implementation: a minimal, extensible toast system
The snippet below is TypeScript‑friendly but works in plain JS by removing types. It includes: Provider, hook, and components with queueing, variants, and a promise helper.
// toast.tsx
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
type ToastType = 'success' | 'error' | 'info' | 'warning' | 'loading';
export type ToastOptions = {
id?: string;
title?: string;
description?: string;
type?: ToastType;
duration?: number; // ms; undefined => manual dismiss
action?: { label: string; onClick: () => void } | null;
dismissible?: boolean;
important?: boolean; // render with role='alert' if true
icon?: React.ReactNode;
key?: string; // for deduping (same key replaces existing)
};
export type Toast = Required<Pick<ToastOptions, 'type'>> & ToastOptions & { id: string; createdAt: number };
type ToastContextValue = {
show: (message: string | Partial<ToastOptions> & { description?: string }) => string; // returns id
success: (message: string, opts?: ToastOptions) => string;
error: (message: string, opts?: ToastOptions) => string;
info: (message: string, opts?: ToastOptions) => string;
warning: (message: string, opts?: ToastOptions) => string;
loading: (message: string, opts?: ToastOptions) => string;
update: (id: string, opts: Partial<ToastOptions>) => void;
dismiss: (id: string) => void;
dismissAll: () => void;
promise: <T>(p: Promise<T>, messages: { loading: string; success: (val: T) => string | string; error: (err: unknown) => string | string }, opts?: Partial<ToastOptions>) => void;
};
const ToastContext = createContext<ToastContextValue | null>(null);
const genId = () => Math.random().toString(36).slice(2, 9);
export function ToastProvider({ children, maxVisible = 3 }: { children: React.ReactNode; maxVisible?: number }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const timers = useRef<Map<string, number>>(new Map());
const [mounted, setMounted] = useState(false);
const portalElRef = useRef<HTMLElement | null>(null);
useEffect(() => {
setMounted(true);
const el = document.createElement('div');
el.setAttribute('id', 'toast-root');
document.body.appendChild(el);
portalElRef.current = el;
return () => {
document.body.removeChild(el);
portalElRef.current = null;
};
}, []);
const scheduleDismiss = useCallback((id: string, duration?: number) => {
if (!duration || duration <= 0) return;
const handle = window.setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id));
timers.current.delete(id);
}, duration);
timers.current.set(id, handle);
}, []);
const showBase = useCallback((input: any, type: ToastType = 'info') => {
const opts: ToastOptions = typeof input === 'string' ? { description: input } : input;
const id = opts.id ?? genId();
setToasts(prev => {
// dedupe by key
const existingIdx = opts.key ? prev.findIndex(t => t.key === opts.key) : -1;
const next: Toast = {
id,
type,
title: opts.title,
description: opts.description,
duration: opts.duration ?? (type === 'error' ? 8000 : type === 'loading' ? undefined : 3500),
action: opts.action ?? null,
dismissible: opts.dismissible ?? true,
important: opts.important ?? (type === 'error'),
icon: opts.icon,
key: opts.key,
createdAt: Date.now(),
};
if (existingIdx >= 0) {
const clone = prev.slice();
clone[existingIdx] = { ...clone[existingIdx], ...next, id: clone[existingIdx].id };
return clone;
}
const queued = [next, ...prev].sort((a, b) => b.createdAt - a.createdAt);
// enforce maxVisible: mark overflow with duration=undefined but kept in list; viewport slices
return queued;
});
scheduleDismiss(id, (typeof input === 'string' ? undefined : input.duration) ?? (type === 'error' ? 8000 : type === 'loading' ? undefined : 3500));
return id;
}, [scheduleDismiss]);
const api = useMemo<ToastContextValue>(() => ({
show: (payload) => showBase(payload, 'info'),
success: (m, o) => showBase({ description: m, ...o }, 'success'),
error: (m, o) => showBase({ description: m, ...o }, 'error'),
info: (m, o) => showBase({ description: m, ...o }, 'info'),
warning: (m, o) => showBase({ description: m, ...o }, 'warning'),
loading: (m, o) => showBase({ description: m, ...o }, 'loading'),
update: (id, opts) => setToasts(prev => prev.map(t => (t.id === id ? { ...t, ...opts } : t))),
dismiss: (id) => {
const h = timers.current.get(id);
if (h) window.clearTimeout(h);
timers.current.delete(id);
setToasts(prev => prev.filter(t => t.id !== id));
},
dismissAll: () => {
timers.current.forEach(h => window.clearTimeout(h));
timers.current.clear();
setToasts([]);
},
promise: async (p, messages, opts) => {
const id = showBase({ description: messages.loading, type: 'loading', ...opts }, 'loading');
try {
const val = await p;
const text = typeof messages.success === 'function' ? messages.success(val) : messages.success;
setToasts(prev => prev.map(t => (t.id === id ? { ...t, type: 'success', description: text, duration: opts?.duration ?? 3500 } : t)));
scheduleDismiss(id, opts?.duration ?? 3500);
} catch (err) {
const text = typeof messages.error === 'function' ? messages.error(err) : messages.error;
setToasts(prev => prev.map(t => (t.id === id ? { ...t, type: 'error', description: text, duration: opts?.duration ?? 8000, important: true } : t)));
scheduleDismiss(id, opts?.duration ?? 8000);
}
},
}), [scheduleDismiss, showBase]);
// Pause timers on hover
const viewportRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const el = viewportRef.current;
if (!el) return;
const onEnter = () => {
timers.current.forEach(h => window.clearTimeout(h));
timers.current.clear();
};
const onLeave = () => {
// reschedule remaining visible toasts with a short grace (1s)
setToasts(prev => {
prev.forEach(t => t.duration && scheduleDismiss(t.id, 1000));
return prev;
});
};
el.addEventListener('mouseenter', onEnter);
el.addEventListener('mouseleave', onLeave);
return () => {
el.removeEventListener('mouseenter', onEnter);
el.removeEventListener('mouseleave', onLeave);
};
}, [scheduleDismiss, toasts.length]);
const visible = useMemo(() => toasts.slice(0, maxVisible), [toasts, maxVisible]);
return (
<ToastContext.Provider value={api}>
{children}
{mounted && portalElRef.current && createPortal(
<div ref={viewportRef} aria-live='polite' aria-atomic='false' className='toast-viewport'>
{visible.map(t => (
<ToastItem key={t.id} toast={t} onClose={() => api.dismiss(t.id)} />
))}
</div>,
portalElRef.current
)}
</ToastContext.Provider>
);
}
function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
const role = toast.important ? 'alert' : 'status';
return (
<div className={`toast toast-${toast.type}`} role={role} tabIndex={0}>
{toast.icon && <span className='toast-icon'>{toast.icon}</span>}
<div className='toast-body'>
{toast.title && <div className='toast-title'>{toast.title}</div>}
{toast.description && <div className='toast-desc'>{toast.description}</div>}
</div>
{toast.action && (
<button className='toast-action' onClick={toast.action.onClick}>{toast.action.label}</button>
)}
{toast.dismissible && (
<button aria-label='Dismiss' className='toast-dismiss' onClick={onClose}>×</button>
)}
</div>
);
}
export function useToast() {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error('useToast must be used within <ToastProvider/>');
return ctx;
}
Usage
// App.tsx
import { ToastProvider, useToast } from './toast';
function SaveButton() {
const toast = useToast();
const onSave = async () => {
toast.promise(
fakeSave(),
{
loading: 'Saving draft…',
success: () => 'Draft saved',
error: () => 'Could not save. Please try again.',
},
{ key: 'save' }
);
};
return <button onClick={onSave}>Save</button>;
}
export default function App() {
return (
<ToastProvider>
<SaveButton />
</ToastProvider>
);
}
Styling and theming
Use CSS variables for themeable colors and subtle motion. The viewport should be fixed to one corner and stack toasts with gaps.
/* toast.css */
:root {
--toast-bg: #1f2937; /* slate-800 */
--toast-fg: #f9fafb; /* gray-50 */
--toast-success: #16a34a;
--toast-error: #dc2626;
--toast-warning: #d97706;
--toast-info: #3b82f6;
--toast-shadow: 0 10px 30px rgba(0,0,0,0.35);
}
.toast-viewport {
position: fixed;
top: 16px;
right: 16px;
display: flex;
flex-direction: column;
gap: 10px;
z-index: 9999;
pointer-events: none; /* container ignores clicks */
}
.toast {
display: grid;
grid-template-columns: auto 1fr auto auto;
align-items: center;
gap: 10px;
min-width: 280px;
max-width: 420px;
padding: 10px 12px;
border-radius: 10px;
color: var(--toast-fg);
background: var(--toast-bg);
box-shadow: var(--toast-shadow);
pointer-events: auto; /* clickable children */
animation: slide-in 200ms ease, fade-out 200ms ease reverse paused;
}
.toast-success { border-left: 4px solid var(--toast-success); }
.toast-error { border-left: 4px solid var(--toast-error); }
.toast-warning { border-left: 4px solid var(--toast-warning); }
.toast-info { border-left: 4px solid var(--toast-info); }
.toast-loading { border-left: 4px solid #a3a3a3; }
.toast-title { font-weight: 600; }
.toast-desc { opacity: 0.9; }
.toast-action, .toast-dismiss {
background: transparent; border: 0; color: inherit; cursor: pointer; font-weight: 600;
}
@keyframes slide-in { from { transform: translateY(-8px); opacity: 0 } to { transform: translateY(0); opacity: 1 } }
@media (prefers-reduced-motion: reduce) {
.toast { animation: none; }
}
Tip: for dark/light themes, override the variables in a [data-theme=‘light’] attribute or with Tailwind’s theme tokens.
Accessibility essentials
- Live regions: role=‘status’ (polite) for most messages; role=‘alert’ (assertive) for important errors. The example selects automatically via important.
- Dismiss buttons: always keyboard focusable; provide aria-label.
- Focus strategy: don’t steal focus by default; let the toast receive focus only if the user tabs into it.
- Reduced motion: respect prefers-reduced-motion to disable animations.
- Color contrast: verify AA contrast for text and borders.
Animations that feel native
CSS animations are cheap and sufficient. If you need enter/exit choreography or spring physics, integrate a small wrapper with Framer Motion:
// Inside ToastItem body
// <motion.div initial={{ y: -8, opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={{ y: -8, opacity: 0 }} layout />
Remember to use AnimatePresence around the mapped list to support exit animations.
Advanced features
- Deduping and replacement: use a stable key (e.g., ‘save’) so repeated events update the same toast.
- Queueing: we capped visible toasts to maxVisible and slice in the viewport; you can auto‑promote queued items as others dismiss.
- Progress: add a progress bar driven by CSS animation or remaining time; pause on hover is already implemented.
- Promise toasts: included via toast.promise—great for network requests.
- Placement variants: support ’top-right’, ‘bottom-right’, etc. by applying a class to the viewport.
- Theming: expose className props and/or CSS variables for brand alignment.
Server rendering (Next.js) considerations
- Portals and document access: the provider creates a portal only after mount to avoid SSR mismatches.
- Edge/runtime constraints: if you trigger toasts during SSR, guard with typeof window !== ‘undefined’.
- App Router: place ToastProvider high in the tree (e.g., in app/layout.tsx) so any route can emit toasts.
Testing your toast system
Use React Testing Library and fake timers to assert timing behavior.
import { render, screen, act } from '@testing-library/react';
import { ToastProvider, useToast } from './toast';
function Emit() {
const toast = useToast();
return <button onClick={() => toast.success('Saved')}>Emit</button>;
}
test('auto-dismisses', () => {
jest.useFakeTimers();
render(<ToastProvider><Emit/></ToastProvider>);
screen.getByText('Emit').click();
expect(screen.getByText('Saved')).toBeInTheDocument();
act(() => { jest.advanceTimersByTime(4000); });
expect(screen.queryByText('Saved')).toBeNull();
});
Common pitfalls and how to avoid them
- Notification overload: cap visible count and dedupe by intent (key prop).
- Obscuring core UI: keep the viewport off critical interactive areas and use pointer-events wisely.
- Unclear messaging: write from the user’s perspective; avoid internal error codes.
- Trapping focus: never autofocus toasts unless explicitly required; ensure escape routes via Tab and dismiss.
- Z-index wars: keep a single portal container with a consistent, high z-index token.
Performance notes
- Keep components pure and lightweight; avoid re‑rendering the whole app when a toast changes—context here only exposes functions, while the portal renders independently.
- Prefer CSS animations over JS for transitions.
- Icons: inline small SVGs or use a shared sprite to cut network overhead.
Quick checklist
- Accessible roles and labels
- Consistent placement and theming
- Capped stacking + queueing
- Pause on hover; dismissible
- Promise helper for async flows
- Tests with fake timers
Conclusion
A toast system shines when it’s invisible most of the time and immediately helpful when needed. By combining a small store, a portal viewport, careful accessibility, and a few ergonomic APIs, you get a robust, design‑system‑ready solution that scales across your React app. Start with the minimal implementation above, then iterate with theming, motion, and helpers tailored to your product’s voice and behavior.
Related Posts
React Search Autocomplete: A Fast, Accessible Implementation with Hooks
Build a fast, accessible React search autocomplete with hooks, debouncing, ARIA, caching, and virtualization. Includes complete, production-ready code.
Mastering React Virtualized Lists: Performance Patterns and Tuning Guide
Build fast, memory‑efficient React virtualized lists with tuning tips for overscan, variable heights, a11y, and profiling—plus practical code examples.
React useMemo and useCallback: A Practical Optimization Guide
Practical guide to React’s useMemo and useCallback: when to use them, pitfalls to avoid, patterns, and profiling tips for faster apps.