Back to Blog

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.

Viprasol Tech Team
May 14, 2027
11 min read

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

ApproachTeamTimelineCost Range
Sonner integration + configuration1 dev2–4 hours$100–200
Custom toast system from scratch1 dev1–2 days$400–800
+ Undo action + promise helper + dedup1 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


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:

  • ToastProvider with useReducer, max-5 cap, deduplication by dedupKey
  • useToast hook: success, error, warning, info, promise, dismiss
  • ToastItem: animated mount/exit, progress bar countdown, action button, X dismiss
  • Toaster container: 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.

Share this article:

About the Author

V

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.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

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

Viprasol · Web Development

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.