React Toast Notifications: Queue System, Variants, Auto-Dismiss, and Action Buttons
Build a production toast notification system in React without a library. Covers useToast hook, notification queue with deduplication, multiple variants (success/error/warning/info), auto-dismiss with progress bar, action buttons, and accessibility.
Most projects start with alert(), graduate to a toast library, then discover the library doesn't support the exact behavior they need (deduplication, async actions, undo). Building your own toast system takes one afternoon and gives you complete control. This guide covers a production-quality implementation — or if you prefer using Sonner (the most popular 2026 option), how to configure it correctly.
Option A: Sonner (Recommended for Most Projects)
npm install sonner
// app/layout.tsx
import { Toaster } from "sonner";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
{children}
<Toaster
position="bottom-right"
richColors // Semantic colors per variant
closeButton // Show X button
duration={4000} // Auto-dismiss after 4s
toastOptions={{
classNames: {
toast: "font-sans text-sm",
description: "text-gray-500",
actionButton: "bg-blue-600 text-white text-xs font-semibold px-3 py-1 rounded",
cancelButton: "text-gray-500 text-xs",
},
}}
/>
</body>
</html>
);
}
// Usage anywhere in the app
import { toast } from "sonner";
// Variants
toast.success("Profile saved");
toast.error("Failed to save — please try again");
toast.warning("Your session expires in 5 minutes");
toast.info("You have 3 new notifications");
// With action button
toast.success("Invoice sent", {
action: {
label: "View invoice",
onClick: () => router.push("/invoices/inv_123"),
},
});
// Promise toast (loading → success/error)
toast.promise(
sendInvoice(invoiceId),
{
loading: "Sending invoice…",
success: (result) => `Invoice sent to ${result.recipientEmail}`,
error: (err) => `Failed: ${err.message}`,
}
);
// Persistent (no auto-dismiss) with close button
toast.error("Critical error — action required", {
duration: Infinity,
description: "Your subscription payment failed. Update your card to continue.",
action: {
label: "Update billing",
onClick: () => router.push("/billing"),
},
});
Option B: Build Your Own
For teams that want full control:
Context and State
// lib/toast/types.ts
export type ToastVariant = "success" | "error" | "warning" | "info";
export interface Toast {
id: string;
title: string;
description?: string;
variant: ToastVariant;
duration: number; // ms; Infinity for persistent
action?: { label: string; onClick: () => void };
dedupKey?: string; // If set, deduplicate by this key
}
export interface ToastState {
toasts: Toast[];
}
// lib/toast/context.tsx
"use client";
import {
createContext, useContext, useReducer, useCallback,
type ReactNode
} from "react";
import type { Toast, ToastVariant } from "./types";
type Action =
| { type: "ADD"; toast: Toast }
| { type: "REMOVE"; id: string };
function reducer(state: Toast[], action: Action): Toast[] {
switch (action.type) {
case "ADD": {
// Deduplicate by dedupKey if provided
if (action.toast.dedupKey) {
const exists = state.some((t) => t.dedupKey === action.toast.dedupKey);
if (exists) return state;
}
// Max 5 toasts at once — remove oldest if needed
const capped = state.length >= 5 ? state.slice(1) : state;
return [...capped, action.toast];
}
case "REMOVE":
return state.filter((t) => t.id !== action.id);
default:
return state;
}
}
interface ToastContextValue {
toasts: Toast[];
toast: (options: Omit<Toast, "id"> & { id?: string }) => string;
dismiss: (id: string) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, dispatch] = useReducer(reducer, []);
const toast = useCallback(
(options: Omit<Toast, "id"> & { id?: string }): string => {
const id = options.id ?? crypto.randomUUID();
dispatch({
type: "ADD",
toast: { duration: 4000, ...options, id },
});
return id;
},
[]
);
const dismiss = useCallback((id: string) => {
dispatch({ type: "REMOVE", id });
}, []);
return (
<ToastContext.Provider value={{ toasts, toast, dismiss }}>
{children}
</ToastContext.Provider>
);
}
export function useToastContext(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error("useToastContext must be used within ToastProvider");
return ctx;
}
Convenience Hook
// hooks/use-toast.ts
"use client";
import { useCallback } from "react";
import { useToastContext } from "@/lib/toast/context";
import type { ToastVariant, Toast } from "@/lib/toast/types";
type ToastInput = {
title: string;
description?: string;
duration?: number;
action?: Toast["action"];
dedupKey?: string;
};
export function useToast() {
const { toast, dismiss } = useToastContext();
const success = useCallback(
(title: string, options?: Omit<ToastInput, "title">) =>
toast({ ...options, title, variant: "success" }),
[toast]
);
const error = useCallback(
(title: string, options?: Omit<ToastInput, "title">) =>
toast({ ...options, title, variant: "error", duration: options?.duration ?? 6000 }),
[toast]
);
const warning = useCallback(
(title: string, options?: Omit<ToastInput, "title">) =>
toast({ ...options, title, variant: "warning" }),
[toast]
);
const info = useCallback(
(title: string, options?: Omit<ToastInput, "title">) =>
toast({ ...options, title, variant: "info" }),
[toast]
);
// Promise toast helper
const promise = useCallback(
async <T>(
promiseFn: Promise<T>,
messages: { loading: string; success: string | ((data: T) => string); error: string | ((err: Error) => string) }
): Promise<T> => {
const id = toast({ title: messages.loading, variant: "info", duration: Infinity });
try {
const result = await promiseFn;
dismiss(id);
const successMsg = typeof messages.success === "function"
? messages.success(result)
: messages.success;
success(successMsg);
return result;
} catch (err) {
dismiss(id);
const errMsg = typeof messages.error === "function"
? messages.error(err as Error)
: messages.error;
error(errMsg);
throw err;
}
},
[toast, dismiss, success, error]
);
return { success, error, warning, info, promise, dismiss };
}
Toast Item Component
// components/toast/toast-item.tsx
"use client";
import { useEffect, useState, useRef } from "react";
import { X, CheckCircle, XCircle, AlertTriangle, Info } from "lucide-react";
import type { Toast, ToastVariant } from "@/lib/toast/types";
const VARIANT_CONFIG: Record<ToastVariant, {
icon: React.ComponentType<{ className?: string }>;
bg: string;
iconColor: string;
border: string;
bar: string;
}> = {
success: { icon: CheckCircle, bg: "bg-white", iconColor: "text-green-500", border: "border-green-100", bar: "bg-green-500" },
error: { icon: XCircle, bg: "bg-white", iconColor: "text-red-500", border: "border-red-100", bar: "bg-red-500" },
warning: { icon: AlertTriangle, bg: "bg-white", iconColor: "text-amber-500", border: "border-amber-100", bar: "bg-amber-500" },
info: { icon: Info, bg: "bg-white", iconColor: "text-blue-500", border: "border-blue-100", bar: "bg-blue-500" },
};
interface ToastItemProps {
toast: Toast;
onDismiss: (id: string) => void;
}
export function ToastItem({ toast, onDismiss }: ToastItemProps) {
const [progress, setProgress] = useState(100);
const [visible, setVisible] = useState(false);
const intervalRef = useRef<NodeJS.Timeout>();
const config = VARIANT_CONFIG[toast.variant];
const Icon = config.icon;
// Mount animation
useEffect(() => {
requestAnimationFrame(() => setVisible(true));
}, []);
// Auto-dismiss countdown
useEffect(() => {
if (toast.duration === Infinity) return;
const startTime = Date.now();
intervalRef.current = setInterval(() => {
const elapsed = Date.now() - startTime;
const pct = Math.max(0, 100 - (elapsed / toast.duration) * 100);
setProgress(pct);
if (pct <= 0) {
clearInterval(intervalRef.current);
handleDismiss();
}
}, 50);
return () => clearInterval(intervalRef.current);
}, [toast.duration]);
function handleDismiss() {
setVisible(false);
setTimeout(() => onDismiss(toast.id), 200); // Wait for exit animation
}
return (
<div
role="alert"
aria-live="assertive"
aria-atomic="true"
className={`
relative w-[360px] bg-white rounded-xl shadow-lg border overflow-hidden
transition-all duration-200
${config.border}
${visible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-2"}
`}
>
{/* Progress bar */}
{toast.duration !== Infinity && (
<div className="absolute top-0 left-0 right-0 h-0.5 bg-gray-100">
<div
className={`h-full ${config.bar} transition-none`}
style={{ width: `${progress}%` }}
/>
</div>
)}
<div className="flex items-start gap-3 px-4 py-3.5 pt-4">
<Icon className={`w-5 h-5 flex-shrink-0 mt-0.5 ${config.iconColor}`} />
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-gray-900">{toast.title}</p>
{toast.description && (
<p className="text-xs text-gray-500 mt-0.5">{toast.description}</p>
)}
{toast.action && (
<button
onClick={() => { toast.action!.onClick(); handleDismiss(); }}
className="mt-2 text-xs font-semibold text-blue-600 hover:text-blue-700"
>
{toast.action.label} →
</button>
)}
</div>
<button
onClick={handleDismiss}
className="flex-shrink-0 text-gray-400 hover:text-gray-600 transition-colors"
aria-label="Dismiss notification"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
);
}
Toast Container
// components/toast/toaster.tsx
"use client";
import { useToastContext } from "@/lib/toast/context";
import { ToastItem } from "./toast-item";
export function Toaster() {
const { toasts, dismiss } = useToastContext();
return (
<div
aria-label="Notifications"
className="fixed bottom-4 right-4 z-[100] flex flex-col gap-2 items-end"
>
{toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} onDismiss={dismiss} />
))}
</div>
);
}
// app/layout.tsx — include both provider and toaster
import { ToastProvider } from "@/lib/toast/context";
import { Toaster } from "@/components/toast/toaster";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<ToastProvider>
{children}
<Toaster />
</ToastProvider>
</body>
</html>
);
}
🌐 Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different — senior engineers only, direct Slack access, and a 5.0★ Upwork record across 100+ projects.
- React, Next.js, Node.js, TypeScript — production-grade stack
- Fixed-price contracts — no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
Usage Examples
// In any client component
"use client";
import { useToast } from "@/hooks/use-toast";
function SaveButton({ onSave }: { onSave: () => Promise<void> }) {
const toast = useToast();
async function handleSave() {
await toast.promise(onSave(), {
loading: "Saving changes…",
success: "Changes saved",
error: (err) => `Failed to save: ${err.message}`,
});
}
return <button onClick={handleSave}>Save</button>;
}
// With deduplication (won't stack identical toasts)
function ConnectionStatus() {
const toast = useToast();
function onDisconnect() {
toast.warning("Connection lost — reconnecting…", {
dedupKey: "connection-lost",
duration: Infinity,
});
}
}
Cost and Timeline Estimates
| Approach | Team | Timeline | Cost Range |
|---|---|---|---|
| Sonner integration + configuration | 1 dev | 2–4 hours | $100–200 |
| Custom toast system from scratch | 1 dev | 1–2 days | $400–800 |
| + Undo action + promise helper + dedup | 1 dev | + 1 day | $300–600 |
🚀 Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4–8 weeks, full platforms in 3–5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
See Also
- React Error Boundaries
- React Context Patterns
- SaaS In-App Notifications
- React Optimistic Updates
- React Design System with Radix UI
Working With Viprasol
Toast notifications are a UX micro-detail that users notice when they're wrong (stacked duplicates, no dismiss button, covering content) but don't consciously appreciate when they're right. Our team builds toast systems with auto-dismiss progress bars, deduplication by key, promise helpers for async actions, and ARIA live regions for screen reader accessibility.
What we deliver:
ToastProviderwithuseReducer, max-5 cap, deduplication bydedupKeyuseToasthook:success,error,warning,info,promise,dismissToastItem: animated mount/exit, progress bar countdown, action button, X dismissToastercontainer: fixed bottom-right, stacked, aria-label="Notifications"- Sonner integration guide for teams preferring a library
Talk to our team about your UI component needs →
Or explore our web development services.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need a Modern Web Application?
From landing pages to complex SaaS platforms — we build it all with Next.js and React.
Free consultation • No commitment • Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse ≥90 scores, mobile-first design, and full source code ownership. Senior engineers only — from architecture through deployment.