React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

toastnotificationalertsnackbarcomponenttailwind

Toast

A brief, auto-dismissing notification that appears at the edge of the screen to inform users of the result of an action without interrupting their workflow.

Use Cases

  • Confirm a successful form submission (item saved, profile updated)
  • Warn about a non-critical issue (slow connection, feature deprecation)
  • Show error feedback after a failed API call
  • Inform about background events (new message received, file uploaded)
  • Display undo actions after destructive operations (item deleted — undo)
  • Notify about permission or access changes

Simplest Implementation

"use client";
 
import { useState, useEffect } from "react";
 
interface ToastProps {
  message: string;
  open: boolean;
  onClose: () => void;
  duration?: number;
}
 
export function Toast({ message, open, onClose, duration = 3000 }: ToastProps) {
  useEffect(() => {
    if (!open) return;
    const timer = setTimeout(onClose, duration);
    return () => clearTimeout(timer);
  }, [open, duration, onClose]);
 
  if (!open) return null;
 
  return (
    <div className="fixed bottom-4 right-4 z-50 rounded-lg bg-gray-900 px-4 py-3 text-sm text-white shadow-lg">
      {message}
    </div>
  );
}

A minimal toast that auto-dismisses after duration milliseconds. The useEffect cleanup clears the timer if the toast is closed early or the component unmounts, preventing stale state updates.

Variations

Success / Error / Warning / Info Types

"use client";
 
import { useEffect } from "react";
 
type ToastType = "success" | "error" | "warning" | "info";
 
interface ToastProps {
  message: string;
  type?: ToastType;
  open: boolean;
  onClose: () => void;
  duration?: number;
}
 
const typeClasses: Record<ToastType, string> = {
  success: "bg-green-600 text-white",
  error: "bg-red-600 text-white",
  warning: "bg-yellow-500 text-gray-900",
  info: "bg-blue-600 text-white",
};
 
const typeIcons: Record<ToastType, string> = {
  success: "✓",
  error: "✕",
  warning: "⚠",
  info: "ℹ",
};
 
export function Toast({ message, type = "info", open, onClose, duration = 3000 }: ToastProps) {
  useEffect(() => {
    if (!open) return;
    const timer = setTimeout(onClose, duration);
    return () => clearTimeout(timer);
  }, [open, duration, onClose]);
 
  if (!open) return null;
 
  return (
    <div
      role="alert"
      className={`fixed bottom-4 right-4 z-50 flex items-center gap-2 rounded-lg px-4 py-3 text-sm font-medium shadow-lg ${typeClasses[type]}`}
    >
      <span aria-hidden="true">{typeIcons[type]}</span>
      {message}
    </div>
  );
}

Maps toast types to color classes and icons using Record. The role="alert" attribute ensures screen readers announce the toast immediately when it appears.

With Action Button

"use client";
 
import { useEffect } from "react";
 
interface ToastProps {
  message: string;
  open: boolean;
  onClose: () => void;
  duration?: number;
  action?: {
    label: string;
    onClick: () => void;
  };
}
 
export function Toast({ message, open, onClose, duration = 5000, action }: ToastProps) {
  useEffect(() => {
    if (!open) return;
    const timer = setTimeout(onClose, duration);
    return () => clearTimeout(timer);
  }, [open, duration, onClose]);
 
  if (!open) return null;
 
  return (
    <div className="fixed bottom-4 right-4 z-50 flex items-center gap-3 rounded-lg bg-gray-900 px-4 py-3 text-sm text-white shadow-lg">
      <span>{message}</span>
      {action && (
        <button
          onClick={() => {
            action.onClick();
            onClose();
          }}
          className="shrink-0 rounded px-2 py-1 font-semibold text-blue-400 hover:bg-gray-800"
        >
          {action.label}
        </button>
      )}
    </div>
  );
}

The action button calls its handler and then closes the toast. The duration is set longer (5 seconds) to give users enough time to read and act. The action button uses shrink-0 to prevent it from compressing on narrow screens.

With Progress Bar

"use client";
 
import { useEffect, useState } from "react";
 
interface ToastProps {
  message: string;
  open: boolean;
  onClose: () => void;
  duration?: number;
}
 
export function Toast({ message, open, onClose, duration = 4000 }: ToastProps) {
  const [progress, setProgress] = useState(100);
 
  useEffect(() => {
    if (!open) {
      setProgress(100);
      return;
    }
 
    const interval = 50;
    const step = (interval / duration) * 100;
 
    const timer = setInterval(() => {
      setProgress((prev) => {
        const next = prev - step;
        if (next <= 0) {
          clearInterval(timer);
          onClose();
          return 0;
        }
        return next;
      });
    }, interval);
 
    return () => clearInterval(timer);
  }, [open, duration, onClose]);
 
  if (!open) return null;
 
  return (
    <div className="fixed bottom-4 right-4 z-50 w-80 overflow-hidden rounded-lg bg-gray-900 shadow-lg">
      <div className="px-4 py-3 text-sm text-white">{message}</div>
      <div className="h-1 bg-gray-700">
        <div
          className="h-full bg-blue-500 transition-none"
          style={{ width: `${progress}%` }}
        />
      </div>
    </div>
  );
}

The progress bar drains from 100% to 0% over the duration using setInterval. The transition-none class prevents Tailwind transitions from interfering with the smooth JavaScript-driven animation. Progress resets to 100% when the toast closes.

Stacked Toasts

"use client";
 
import { useState, useCallback, useEffect } from "react";
 
interface ToastItem {
  id: number;
  message: string;
}
 
let toastId = 0;
 
export function useToast() {
  const [toasts, setToasts] = useState<ToastItem[]>([]);
 
  const add = useCallback((message: string) => {
    const id = ++toastId;
    setToasts((prev) => [...prev, { id, message }]);
  }, []);
 
  const remove = useCallback((id: number) => {
    setToasts((prev) => prev.filter((t) => t.id !== id));
  }, []);
 
  return { toasts, add, remove };
}
 
function ToastCard({ item, onRemove }: { item: ToastItem; onRemove: (id: number) => void }) {
  useEffect(() => {
    const timer = setTimeout(() => onRemove(item.id), 3000);
    return () => clearTimeout(timer);
  }, [item.id, onRemove]);
 
  return (
    <div className="rounded-lg bg-gray-900 px-4 py-3 text-sm text-white shadow-lg">
      {item.message}
    </div>
  );
}
 
export function ToastContainer({ toasts, onRemove }: { toasts: ToastItem[]; onRemove: (id: number) => void }) {
  return (
    <div className="fixed bottom-4 right-4 z-50 flex flex-col-reverse gap-2">
      {toasts.map((t) => (
        <ToastCard key={t.id} item={t} onRemove={onRemove} />
      ))}
    </div>
  );
}

Each toast manages its own auto-dismiss timer independently. The container uses flex-col-reverse so new toasts stack above older ones. The useToast hook is placed in a parent component, and ToastContainer is rendered once at the layout level.

Positioned (Top / Bottom)

"use client";
 
import { useEffect } from "react";
 
type Position = "top-right" | "top-left" | "bottom-right" | "bottom-left" | "top-center" | "bottom-center";
 
interface ToastProps {
  message: string;
  open: boolean;
  onClose: () => void;
  position?: Position;
  duration?: number;
}
 
const positionClasses: Record<Position, string> = {
  "top-right": "top-4 right-4",
  "top-left": "top-4 left-4",
  "bottom-right": "bottom-4 right-4",
  "bottom-left": "bottom-4 left-4",
  "top-center": "top-4 left-1/2 -translate-x-1/2",
  "bottom-center": "bottom-4 left-1/2 -translate-x-1/2",
};
 
export function Toast({ message, open, onClose, position = "bottom-right", duration = 3000 }: ToastProps) {
  useEffect(() => {
    if (!open) return;
    const timer = setTimeout(onClose, duration);
    return () => clearTimeout(timer);
  }, [open, duration, onClose]);
 
  if (!open) return null;
 
  return (
    <div
      role="status"
      className={`fixed z-50 rounded-lg bg-gray-900 px-4 py-3 text-sm text-white shadow-lg ${positionClasses[position]}`}
    >
      {message}
    </div>
  );
}

A Record maps position names to Tailwind positioning classes. The centered positions use left-1/2 -translate-x-1/2 for horizontal centering regardless of toast width.

Dismissible with Close Button

"use client";
 
import { useEffect } from "react";
 
interface ToastProps {
  message: string;
  open: boolean;
  onClose: () => void;
  duration?: number;
  dismissible?: boolean;
}
 
export function Toast({ message, open, onClose, duration = 3000, dismissible = true }: ToastProps) {
  useEffect(() => {
    if (!open) return;
    const timer = setTimeout(onClose, duration);
    return () => clearTimeout(timer);
  }, [open, duration, onClose]);
 
  if (!open) return null;
 
  return (
    <div
      role="alert"
      className="fixed bottom-4 right-4 z-50 flex items-center gap-3 rounded-lg bg-gray-900 px-4 py-3 text-sm text-white shadow-lg"
    >
      <span>{message}</span>
      {dismissible && (
        <button
          onClick={onClose}
          aria-label="Dismiss"
          className="shrink-0 rounded p-0.5 text-gray-400 hover:text-white"
        >
          <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
          </svg>
        </button>
      )}
    </div>
  );
}

Adds a close icon that lets users dismiss the toast manually. The aria-label="Dismiss" provides an accessible name for the icon-only button. The auto-dismiss timer still runs, so the toast closes on its own if the user ignores it.

Complex Implementation

"use client";
 
import { createContext, useContext, useCallback, useState, useEffect, useRef } from "react";
import { createPortal } from "react-dom";
 
// --- Types ---
 
type ToastType = "success" | "error" | "warning" | "info";
type Position = "top-right" | "top-left" | "bottom-right" | "bottom-left";
 
interface ToastAction {
  label: string;
  onClick: () => void;
}
 
interface ToastItem {
  id: number;
  message: string;
  type: ToastType;
  duration: number;
  action?: ToastAction;
  dismissible: boolean;
}
 
interface ToastOptions {
  type?: ToastType;
  duration?: number;
  action?: ToastAction;
  dismissible?: boolean;
}
 
interface ToastContextValue {
  toast: (message: string, options?: ToastOptions) => number;
  dismiss: (id: number) => void;
  dismissAll: () => void;
}
 
// --- Context ---
 
const ToastContext = createContext<ToastContextValue | null>(null);
 
export function useToast() {
  const ctx = useContext(ToastContext);
  if (!ctx) throw new Error("useToast must be used inside ToastProvider");
  return ctx;
}
 
// --- Toast card ---
 
const typeConfig: Record<ToastType, { bg: string; icon: string }> = {
  success: { bg: "bg-green-600", icon: "✓" },
  error: { bg: "bg-red-600", icon: "✕" },
  warning: { bg: "bg-yellow-500 text-gray-900", icon: "⚠" },
  info: { bg: "bg-blue-600", icon: "ℹ" },
};
 
function ToastCard({
  item,
  onDismiss,
}: {
  item: ToastItem;
  onDismiss: (id: number) => void;
}) {
  const [visible, setVisible] = useState(false);
  const [progress, setProgress] = useState(100);
  const pausedRef = useRef(false);
 
  useEffect(() => {
    requestAnimationFrame(() => setVisible(true));
  }, []);
 
  useEffect(() => {
    const interval = 50;
    const step = (interval / item.duration) * 100;
 
    const timer = setInterval(() => {
      if (pausedRef.current) return;
      setProgress((prev) => {
        const next = prev - step;
        if (next <= 0) {
          clearInterval(timer);
          setVisible(false);
          setTimeout(() => onDismiss(item.id), 200);
          return 0;
        }
        return next;
      });
    }, interval);
 
    return () => clearInterval(timer);
  }, [item.id, item.duration, onDismiss]);
 
  const config = typeConfig[item.type];
 
  return (
    <div
      role="alert"
      onMouseEnter={() => (pausedRef.current = true)}
      onMouseLeave={() => (pausedRef.current = false)}
      className={`pointer-events-auto w-80 overflow-hidden rounded-lg shadow-lg transition-all duration-200 ${
        visible ? "translate-x-0 opacity-100" : "translate-x-4 opacity-0"
      } ${config.bg} ${item.type === "warning" ? "" : "text-white"}`}
    >
      <div className="flex items-start gap-2 px-4 py-3">
        <span className="mt-0.5 text-sm" aria-hidden="true">
          {config.icon}
        </span>
        <p className="flex-1 text-sm font-medium">{item.message}</p>
        {item.dismissible && (
          <button
            onClick={() => {
              setVisible(false);
              setTimeout(() => onDismiss(item.id), 200);
            }}
            aria-label="Dismiss"
            className="shrink-0 rounded p-0.5 opacity-70 hover:opacity-100"
          >
            <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
            </svg>
          </button>
        )}
      </div>
      {item.action && (
        <div className="px-4 pb-3">
          <button
            onClick={() => {
              item.action!.onClick();
              onDismiss(item.id);
            }}
            className="text-sm font-semibold underline underline-offset-2 opacity-90 hover:opacity-100"
          >
            {item.action.label}
          </button>
        </div>
      )}
      <div className={`h-1 ${item.type === "warning" ? "bg-yellow-600/30" : "bg-white/20"}`}>
        <div
          className="h-full bg-white/50 transition-none"
          style={{ width: `${progress}%` }}
        />
      </div>
    </div>
  );
}
 
// --- Provider ---
 
let nextId = 0;
 
interface ToastProviderProps {
  children: React.ReactNode;
  position?: Position;
  maxToasts?: number;
}
 
const positionClasses: Record<Position, string> = {
  "top-right": "top-4 right-4 flex-col",
  "top-left": "top-4 left-4 flex-col",
  "bottom-right": "bottom-4 right-4 flex-col-reverse",
  "bottom-left": "bottom-4 left-4 flex-col-reverse",
};
 
export function ToastProvider({ children, position = "bottom-right", maxToasts = 5 }: ToastProviderProps) {
  const [toasts, setToasts] = useState<ToastItem[]>([]);
  const [mounted, setMounted] = useState(false);
 
  useEffect(() => setMounted(true), []);
 
  const dismiss = useCallback((id: number) => {
    setToasts((prev) => prev.filter((t) => t.id !== id));
  }, []);
 
  const dismissAll = useCallback(() => setToasts([]), []);
 
  const toast = useCallback(
    (message: string, options: ToastOptions = {}) => {
      const id = ++nextId;
      const item: ToastItem = {
        id,
        message,
        type: options.type ?? "info",
        duration: options.duration ?? 4000,
        action: options.action,
        dismissible: options.dismissible ?? true,
      };
 
      setToasts((prev) => [...prev.slice(-(maxToasts - 1)), item]);
      return id;
    },
    [maxToasts]
  );
 
  return (
    <ToastContext.Provider value={{ toast, dismiss, dismissAll }}>
      {children}
      {mounted &&
        createPortal(
          <div
            aria-live="polite"
            className={`fixed z-50 flex gap-2 pointer-events-none ${positionClasses[position]}`}
          >
            {toasts.map((t) => (
              <ToastCard key={t.id} item={t} onDismiss={dismiss} />
            ))}
          </div>,
          document.body
        )}
    </ToastContext.Provider>
  );
}

Key aspects:

  • Context-driven API -- useToast() returns a toast() function that can be called from any component without prop drilling. The provider renders the toast container via a portal.
  • Pause on hover -- a useRef flag pauses the countdown timer when the user hovers over the toast, using onMouseEnter/onMouseLeave. A ref is used instead of state to avoid re-renders on every interval tick.
  • Slide-in animation -- each toast starts with translate-x-4 opacity-0 and transitions to translate-x-0 opacity-100 via requestAnimationFrame. On dismiss, the reverse plays for 200ms before the item is removed from state.
  • Max toast limit -- slice(-(maxToasts - 1)) trims the oldest toasts when the cap is exceeded, preventing the screen from filling with notifications.
  • Progress bar per toast -- each ToastCard manages its own progress bar independently via setInterval. The bar pauses when hovered.
  • Portal rendering -- createPortal renders the container at document.body to avoid stacking context issues. The container is pointer-events-none while individual cards are pointer-events-auto so clicks pass through the empty space.
  • aria-live="polite" -- the container announces new toasts to screen readers without interrupting the current speech, making the component accessible by default.

Gotchas

  • Stale closure in timer callbacks -- if onClose changes identity between renders (e.g., an inline arrow function), the useEffect cleanup and re-setup can cause the timer to restart. Wrap the parent's onClose in useCallback to keep a stable reference.

  • Multiple toasts overwriting each other -- if you store a single { open, message } in state, firing a second toast before the first finishes replaces the first silently. Use an array-based approach (like the stacked variation) for reliable queuing.

  • Missing role="alert" or aria-live -- without these attributes, screen readers do not announce the toast. Use role="alert" on individual toasts or aria-live="polite" on the container.

  • Z-index conflicts with modals -- toasts fixed at z-50 can appear behind modals or drawers that use higher z-index values. Ensure the toast container's z-index is the highest in your stacking order.

  • Timer not clearing on unmount -- if the component unmounts before setTimeout fires, the callback runs on stale state. Always return a cleanup function from useEffect to clear the timer.

  • Progress bar animation jank -- using CSS transition on the progress bar width competes with the JavaScript interval-driven updates, causing choppy movement. Apply transition-none (Tailwind) to the progress bar and let JavaScript control the width directly.

  • Toast appearing behind the keyboard on mobile -- bottom-positioned toasts can be hidden by the virtual keyboard on mobile devices. Use top-positioned toasts in form-heavy flows, or listen for the visualViewport resize event to adjust placement.

  • Button -- Action buttons used inside toast action slots
  • Modal -- For content that requires user acknowledgment before proceeding
  • Dropdown -- Another floating UI pattern with similar z-index considerations