React Toast Notifications: Build a Sonner-Style Toast System
Learn how to build a Sonner-style React toast notification system from scratch — stacked, animated, and fully accessible without a heavy dependency.
Why Toast Notifications Are Harder Than They Look
You'd think a toast is just a div that pops up, shows a message, and disappears. Then you actually build one. Suddenly you're dealing with stacking order, animation direction, screen readers, auto-dismiss timers that get cancelled on hover, and a global state bus that doesn't cause re-renders everywhere.
Sonner — released by Emil Kowalski in 2023 — cracked this well. It introduced the stacked-card pattern where toasts fan out on hover instead of just stacking vertically like a phone notification tray. It's 3D-transformed, lightweight, and the API is dead simple. But you don't always want a library. Sometimes you want to own the code.
Honestly, understanding the internals is worth the hour it takes. Once you've built it, you can skin it to match any design system — including the kind of polished UI you'd find if you browse the components at Empire UI. Let's get into it.
The Core Architecture: An Event Bus + Singleton Store
The trick Sonner uses — and that most good toast systems share — is a singleton store outside React's component tree. You publish toast events from anywhere in your app, and one <Toaster /> component at the root subscribes and renders them. No context drilling required.
Here's the minimal version. Create a file called toast.ts:
type ToastType = 'success' | 'error' | 'info' | 'warning';
interface Toast {
id: string;
message: string;
type: ToastType;
duration: number;
}
type Listener = (toasts: Toast[]) => void;
const toasts: Toast[] = [];
const listeners: Set<Listener> = new Set();
function notify(listeners: Set<Listener>) {
listeners.forEach(l => l([...toasts]));
}
export function toast(message: string, type: ToastType = 'info', duration = 4000) {
const id = crypto.randomUUID();
toasts.push({ id, message, type, duration });
notify(listeners);
setTimeout(() => {
const idx = toasts.findIndex(t => t.id === id);
if (idx > -1) toasts.splice(idx, 1);
notify(listeners);
}, duration);
}
export function subscribe(listener: Listener) {
listeners.add(listener);
return () => listeners.delete(listener);
}That's the whole state layer. No Redux, no Zustand, no React at all. Just a module-level array and a pub/sub mechanism. Worth noting: crypto.randomUUID() is available in all modern browsers as of 2022 and in Node 19+, so you don't need nanoid unless you're targeting older environments.
The subscribe function returns an unsubscribe callback — that's the pattern you'll use in a useEffect cleanup.
Building the Toaster Component
Now the React side. Your <Toaster /> component subscribes to the store, manages its own local copy of the toast list, and renders the stack. The stacking magic is pure CSS transforms.
import { useEffect, useState } from 'react';
import { subscribe } from './toast';
interface ToastItem {
id: string;
message: string;
type: 'success' | 'error' | 'info' | 'warning';
}
const TYPE_STYLES: Record<string, string> = {
success: 'bg-emerald-950 border-emerald-700 text-emerald-100',
error: 'bg-red-950 border-red-700 text-red-100',
info: 'bg-zinc-900 border-zinc-700 text-zinc-100',
warning: 'bg-amber-950 border-amber-700 text-amber-100',
};
export function Toaster() {
const [items, setItems] = useState<ToastItem[]>([]);
const [hovered, setHovered] = useState(false);
useEffect(() => subscribe(setItems), []);
const visible = items.slice(-5); // cap at 5
return (
<div
className="fixed bottom-4 right-4 z-[9999] flex flex-col-reverse gap-2"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
aria-live="polite"
aria-label="Notifications"
>
{visible.map((toast, i) => {
const offset = visible.length - 1 - i;
const isTop = offset === 0;
const scale = hovered ? 1 : 1 - offset * 0.04;
const translateY = hovered ? 0 : offset * -6;
return (
<div
key={toast.id}
role="alert"
className={`
w-80 px-4 py-3 rounded-xl border text-sm font-medium
shadow-lg transition-all duration-300 ease-out
${TYPE_STYLES[toast.type]}
${!isTop ? 'absolute bottom-0' : ''}
`}
style={{
transform: `translateY(${translateY}px) scale(${scale})`,
zIndex: i,
opacity: hovered ? 1 : 1 - offset * 0.15,
}}
>
{toast.message}
</div>
);
})}
</div>
);
}The key numbers: scale drops by 0.04 per position and translateY shifts by -6px per position. That gives you the stacked-card illusion at rest. On hover, everything snaps to scale(1) and translateY(0), spreading the stack so the user can read all toasts. You can tune those values — in practice, -8px offset and 0.05 scale delta feels slightly more pronounced if you want the effect to read on dense UIs.
Quick aside: the aria-live="polite" on the container and role="alert" on each individual toast is what makes screen readers announce new notifications. Don't skip that. It's two attributes and it's the difference between accessible and not.
Adding Entry and Exit Animations
The component above handles the stacking but the toasts just appear. You want them to slide in from the right and fade out on removal. The cleanest way is CSS @keyframes combined with a short presence delay.
Add this to your global CSS:
@keyframes toast-in {
from {
opacity: 0;
transform: translateX(calc(100% + 16px));
}
to {
opacity: 1;
transform: translateX(0);
}
}
.toast-enter {
animation: toast-in 0.25s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}Then add toast-enter to the className of each toast div. For exit animations you'd track a leaving state per toast ID — add the ID to a Set<string> when the auto-dismiss fires, apply a fade-out class, then remove from state after a 200ms delay. It's a bit of boilerplate but it's the right pattern. Libraries like Framer Motion make this easier with AnimatePresence, but if you're trying to keep the bundle small, the CSS approach is fine.
Look, the cubic-bezier(0.16, 1, 0.3, 1) easing is the one Sonner uses and it's worth keeping. It's the same curve as iOS spring animations — fast out, no overshoot. It reads as responsive without being bouncy. You can preview it on cubic-bezier.com if you want to tweak it.
Triggering Toasts From Anywhere
Now that the plumbing is done, the usage is clean. Drop <Toaster /> once in your app root, then call toast() from any component, hook, or even outside React entirely.
// In your root layout (Next.js app router example)
import { Toaster } from '@/components/Toaster';
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
{children}
<Toaster />
</body>
</html>
);
}
// Then in any component:
import { toast } from '@/lib/toast';
function SaveButton() {
async function handleSave() {
try {
await saveData();
toast('Saved successfully', 'success');
} catch {
toast('Save failed — try again', 'error');
}
}
return <button onClick={handleSave}>Save</button>;
}That's it. No providers, no hooks, no wrapping your component tree. The singleton store approach means you can also call toast() from Axios interceptors, fetch wrappers, or WebSocket event handlers without any ceremony. In practice this is the feature most teams miss when they roll their own solution early on — and then retrofit later when they realise they need toasts in a service layer.
If you're building this into a bigger design system, the type variants map cleanly onto whatever token set you're using. Check out the glassmorphism components on Empire UI if you want to see how a frosted-glass skin on toasts would look — the backdrop-filter: blur(12px) treatment works especially well for notification UIs on top of rich backgrounds.
Dismissal, Persistence, and Action Toasts
Two patterns come up constantly in production: action toasts ("Undo" button on a delete) and persistent toasts that don't auto-dismiss. Both are small extensions to the store.
For persistence, just pass duration: Infinity (or duration: 0 with a guard in your timeout logic). For actions, add an optional action field to your Toast type:
interface Toast {
id: string;
message: string;
type: ToastType;
duration: number;
action?: {
label: string;
onClick: () => void;
};
}Then in the <Toaster />, render the action button conditionally. A 44px minimum touch target on the button, please — it's a notification, users are clicking it while looking elsewhere. Worth noting: if you add an action, you should also expose a manual dismiss(id) function from your store so the action callback can close the toast after firing.
One more thing — don't stack more than 5 toasts. The visible = items.slice(-5) line in the Toaster does this, but also think about the UX: if you're showing 5 simultaneous error toasts, you probably have a different problem to solve. Rate-limit at the call site, or deduplicate by message content in the store.
Styling It to Match Your Design System
The token structure above uses Tailwind classes but the actual visual direction is up to you. The four archetypes that work well for toasts are: minimal dark (zinc-900 background, small border radius, no icons), glassmorphism (backdrop blur, semi-transparent background), colourful solid (full-saturation type colours), and neobrutalist (hard shadow, thick border, flat colour).
If you're building something with strong visual character — say a dashboard with a cyberpunk or vaporwave aesthetic — your toast should match. A neon-glow success toast on a dark terminal UI feels intentional. A plain white toast on the same UI feels like a browser default leaked through.
In practice, toasts are small enough that you can afford to over-engineer the visual. You're only rendering one or two at a time. Gradients, animated borders, custom icons per type — none of that costs you performance at this scale. Use the gradient generator to mock up background treatments before you commit them to code.
The system we've built here is production-ready as-is. Under 100 lines of TypeScript, no dependencies beyond React, fully accessible, and you own every pixel of the animation. That's a better deal than most npm packages give you.
FAQ
No — Sonner is excellent if you want a drop-in with zero config, but the custom approach gives you full design control and no third-party dependency. For most design systems, rolling your own is worth it.
In the store's toast() function, check if an identical message already exists in the array before pushing. Something like if (toasts.some(t => t.message === message)) return; handles the common case.
Yes — that's the whole point of the singleton store pattern. You can call toast() from fetch interceptors, WebSocket handlers, or any module-level code. The Toaster component subscribes and React handles the rendering.
4000ms (4 seconds) is the standard and what Sonner defaults to. Error toasts should be longer — 6000ms — or persistent, since users need time to read and act on them.