Toaster

A stacked notification toast component with smooth animations.

Toaster

Preview
Source Code
Source Code
tsx
"use client";

import { useState, useEffect } from "react";
import { motion, AnimatePresence } from "motion/react";
import { X, CheckCircle2, AlertCircle, Info } from "lucide-react";
import { cn } from "@/lib/utils";

type ToastType = "success" | "error" | "info";

interface Toast {
  id: string;
  message: string;
  type: ToastType;
  icon?: React.ReactNode;
}

const ToastItem = ({
  toast,
  removeToast,
}: {
  toast: Toast;
  removeToast: (id: string) => void;
}) => {
  useEffect(() => {
    const timer = setTimeout(() => {
      removeToast(toast.id);
    }, 4000);

    return () => clearTimeout(timer);
  }, [toast.id, removeToast]);

  return (
    <motion.div
      layout
      initial={{ opacity: 0, y: 50, scale: 0.9 }}
      animate={{ opacity: 1, y: 0, scale: 1 }}
      exit={{ opacity: 0, scale: 0.9, transition: { duration: 0.2 } }}
      className={cn(
        "pointer-events-auto flex w-full max-w-sm items-center gap-3 rounded-md border border-white/10 bg-white/80 p-4 backdrop-blur-md dark:bg-zinc-900/80",
        "dark:border-zinc-800",
      )}
    >
      <div
        className={cn("flex h-8 w-8 items-center justify-center rounded-full", {
          "bg-green-500/10 text-green-600 dark:text-green-500":
            toast.type === "success",
          "bg-red-500/10 text-red-600 dark:text-red-500":
            toast.type === "error",
          "bg-blue-500/10 text-blue-600 dark:text-blue-500":
            toast.type === "info",
        })}
      >
        {toast.icon ? (
          toast.icon
        ) : (
          <>
            {toast.type === "success" && (
              <CheckCircle2 className={cn("h-5 w-5")} />
            )}
            {toast.type === "error" && (
              <AlertCircle className={cn("h-5 w-5")} />
            )}
            {toast.type === "info" && <Info className={cn("h-5 w-5")} />}
          </>
        )}
      </div>

      <div className={cn("flex-1")}>
        <h3
          className={cn("text-sm font-medium text-zinc-900 dark:text-zinc-100")}
        >
          {toast.type.charAt(0).toUpperCase() + toast.type.slice(1)}
        </h3>
        <p className={cn("text-sm text-zinc-500 dark:text-zinc-400")}>
          {toast.message}
        </p>
      </div>

      <button
        onClick={() => removeToast(toast.id)}
        className={cn(
          "cursor-pointer text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200",
        )}
      >
        <X className={cn("h-5 w-5")} />
      </button>
    </motion.div>
  );
};

const positionClasses = {
  "top-left":
    "items-start justify-start sm:items-start sm:justify-start flex-col",
  "top-center":
    "items-center justify-start sm:items-center sm:justify-start flex-col",
  "top-right":
    "items-center justify-start sm:items-end sm:justify-start flex-col",
  "bottom-left":
    "items-start justify-start sm:items-start sm:justify-start flex-col-reverse",
  "bottom-center":
    "items-center justify-start sm:items-center sm:justify-start flex-col-reverse",
  "bottom-right":
    "items-center justify-start sm:items-end sm:justify-start flex-col-reverse",
};

export interface ToasterProps {
  toasts: Toast[];
  removeToast: (id: string) => void;
  position?: keyof typeof positionClasses;
}

export const Toaster = ({
  toasts,
  removeToast,
  position = "top-right",
}: ToasterProps) => {
  return (
    <div
      className={cn(
        "pointer-events-none absolute inset-0 flex gap-2 p-4",
        positionClasses[position],
      )}
    >
      {/* <AnimatePresence> */}
      {toasts.map((toast) => (
        <ToastItem key={toast.id} toast={toast} removeToast={removeToast} />
      ))}
      {/* </AnimatePresence> */}
    </div>
  );
};

const ToasterDemo = () => {
  const [toasts, setToasts] = useState<Toast[]>([]);

  const addToast = (
    type: ToastType,
    message: string,
    icon?: React.ReactNode,
  ) => {
    const id = Math.random().toString(36).substring(7);
    setToasts((prev) => [...prev, { id, message, type, icon }]);
  };

  const removeToast = (id: string) => {
    setToasts((prev) => prev.filter((toast) => toast.id !== id));
  };

  return (
    <div
      className={cn(
        "relative flex min-h-[400px] w-full flex-col items-center justify-center gap-4 overflow-hidden rounded-md border border-zinc-200 bg-zinc-50 p-8 dark:border-zinc-800 dark:bg-zinc-950/50",
      )}
    >
      <div className={cn("flex flex-wrap gap-4")}>
        <button
          onClick={() =>
            addToast(
              "success",
              "Changes saved successfully!",
              <CheckCircle2 className={cn("h-5 w-5")} />,
            )
          }
          className={cn(
            "cursor-pointer rounded-md bg-white px-4 py-2 text-sm font-medium text-zinc-900 shadow-sm ring-1 ring-zinc-200 transition-all hover:bg-zinc-50 dark:bg-zinc-900 dark:text-zinc-100 dark:ring-zinc-800 dark:hover:bg-zinc-800",
          )}
        >
          Add Success Toast
        </button>
        <button
          onClick={() =>
            addToast(
              "error",
              "Failed to save changes.",
              <AlertCircle className={cn("h-5 w-5")} />,
            )
          }
          className={cn(
            "cursor-pointer rounded-md bg-white px-4 py-2 text-sm font-medium text-zinc-900 shadow-sm ring-1 ring-zinc-200 transition-all hover:bg-zinc-50 dark:bg-zinc-900 dark:text-zinc-100 dark:ring-zinc-800 dark:hover:bg-zinc-800",
          )}
        >
          Add Error Toast
        </button>
        <button
          onClick={() =>
            addToast(
              "info",
              "New update available.",
              <Info className={cn("h-5 w-5")} />,
            )
          }
          className={cn(
            "cursor-pointer rounded-md bg-white px-4 py-2 text-sm font-medium text-zinc-900 shadow-sm ring-1 ring-zinc-200 transition-all hover:bg-zinc-50 dark:bg-zinc-900 dark:text-zinc-100 dark:ring-zinc-800 dark:hover:bg-zinc-800",
          )}
        >
          Add Info Toast
        </button>
      </div>

      <Toaster toasts={toasts} removeToast={removeToast} position="top-right" />
    </div>
  );
};

export default ToasterDemo;
Share this post