CappyUI LogoCappyUI

Spam Notifications

An animated notification stack component showcasing spam-like notifications with glassmorphism effects and smooth pop-in animations.

Preview

Generate with AI

Want to create this component using AI? Copy the prompt below and paste it into any LLM (ChatGPT, Claude, etc.):

Installation

npx shadcn@latest add https://uiregistry.cappychat.com/registry/spam-notifications.json

Install dependencies

npm i framer-motion lucide-react clsx tailwind-merge

Add util file

lib/utils.ts

import { ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

Copy the source code

components/spam-notifications.tsx

src/components/components/spam-notifications.tsx
"use client";

import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
  Bell,
  Mail,
  Star,
  Gift,
  Zap,
  Trophy,
  Shield,
  Rocket,
} from "lucide-react";
import { cn } from "@/lib/utils";

interface NotificationMessage {
  id: string;
  icon: React.ComponentType<{ className?: string }>;
  title: string;
  subtitle: string;
  time: string;
  color: string;
}

const ALL_NOTIFICATION_MESSAGES: NotificationMessage[] = [
  {
    id: "1",
    icon: Gift,
    title: "Amazon",
    time: "6 min ago",
    subtitle:
      "Congratulations! You've won a $1,000 gift card.Offer ends in 2 days. So hurry up and claim it before it's too late!",
    color: "bg-orange-500/90",
  },
  {
    id: "2",
    icon: Mail,
    time: "10 min ago",
    title: "Security Alert",
    subtitle:
      "Unusual activity detected. Verify your identity now to avoid account suspension. Offer ends in 2 days. So hurry up and claim it before it's too late!",
    color: "bg-red-500/90",
  },
  {
    id: "3",
    icon: Bell,
    time: "15 min ago",
    title: "Dating App",
    subtitle:
      "You have 23 new matches.nearby Offer ends in 2 days. So hurry up and claim it before it's too late!",
    color: "bg-pink-500/90",
  },
  {
    id: "4",
    icon: Zap,
    time: "24 min ago",
    title: "Flash Sale",
    subtitle:
      "90% off everything - ends in 5 minutes. you should claimed it before it's too late!",
    color: "bg-yellow-500/90",
  },
  {
    id: "5",
    icon: Shield,
    time: "2:48 PM",
    title: "McAfee Security",
    subtitle:
      "3 viruses detected on your device.Go and remove them before it's too late!",
    color: "bg-blue-500/90",
  },
  {
    id: "6",
    icon: Trophy,
    time: "3:12 PM",
    title: "Apple Store",
    subtitle:
      "You're our millionth visitor! Claim your iPhone. It is the best deal!",
    color: "bg-purple-500/90",
  },
  {
    id: "7",
    icon: Rocket,
    time: "3:15 PM",
    title: "Investment Opportunity",
    subtitle:
      "Make $5,000/day working from home.Just click the link and start earning now!",
    color: "bg-green-500/90",
  },
  {
    id: "8",
    icon: Star,
    time: "3:17 PM",
    title: "Exclusive Reward",
    subtitle:
      "Spin the wheel to unlock hidden bonuses.You can win a lifetime supply of apple products.",
    color: "bg-amber-500/90",
  },
];

const MAX_NOTIFICATIONS = 8;
const STAGGER_DELAY = 450;
const DISPLAY_DURATION = 4500;
const FADE_DURATION = 1200;

interface SpamNotificationsProps {
  className?: string;
}

export default function SpamNotifications({
  className,
}: SpamNotificationsProps) {
  const [visibleNotifications, setVisibleNotifications] = useState<
    NotificationMessage[]
  >([]);
  const [animationPhase, setAnimationPhase] = useState<
    "stacking" | "displaying" | "fading"
  >("stacking");
  const [animationCycle, setAnimationCycle] = useState(0);
  const [isMounted, setIsMounted] = useState(false);
  const isActiveRef = useRef(false);
  const timeoutIdsRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());

  const messagesToDisplay = useMemo(
    () => ALL_NOTIFICATION_MESSAGES.slice(0, MAX_NOTIFICATIONS),
    []
  );

  const scheduleTimeout = useCallback((handler: () => void, delay: number) => {
    const timeoutId = setTimeout(() => {
      timeoutIdsRef.current.delete(timeoutId);
      handler();
    }, delay);

    timeoutIdsRef.current.add(timeoutId);
    return timeoutId;
  }, []);

  const clearAllTimeouts = useCallback(() => {
    timeoutIdsRef.current.forEach((timeoutId) => {
      clearTimeout(timeoutId);
    });
    timeoutIdsRef.current.clear();
  }, []);

  const startAnimation = useCallback(() => {
    if (!isActiveRef.current) {
      return;
    }

    clearAllTimeouts();
    setVisibleNotifications([]);
    setAnimationPhase("stacking");

    // Update cycle first, then add notifications after a small delay
    setAnimationCycle((prev) => {
      const newCycle = prev + 1;

      // Add notifications one by one with staggered timing
      messagesToDisplay.forEach((notification, index) => {
        scheduleTimeout(() => {
          if (!isActiveRef.current) {
            return;
          }
          setVisibleNotifications((prev) => [...prev, notification]);
        }, index * STAGGER_DELAY + 150); // Small delay to ensure cycle is updated
      });

      return newCycle;
    });

    const totalMessages = messagesToDisplay.length;
    const stackingDuration = totalMessages * STAGGER_DELAY + 650;

    // After all notifications are added, allow them to linger before fading out
    scheduleTimeout(() => {
      if (!isActiveRef.current) {
        return;
      }
      setAnimationPhase("displaying");
      scheduleTimeout(() => {
        if (!isActiveRef.current) {
          return;
        }
        setAnimationPhase("fading");
        scheduleTimeout(() => {
          if (!isActiveRef.current) {
            return;
          }
          setVisibleNotifications([]); // Clear notifications first
          scheduleTimeout(() => {
            if (!isActiveRef.current) {
              return;
            }
            startAnimation(); // Restart the cycle after clearing
          }, 200);
        }, FADE_DURATION);
      }, DISPLAY_DURATION);
    }, stackingDuration); // Updated to account for the delay offset
  }, [clearAllTimeouts, messagesToDisplay, scheduleTimeout]);

  useEffect(() => {
    isActiveRef.current = true;
    setIsMounted(true);

    return () => {
      isActiveRef.current = false;
      clearAllTimeouts();
    };
  }, [clearAllTimeouts]);

  useEffect(() => {
    if (isMounted) {
      startAnimation();
    }
  }, [isMounted, startAnimation]);

  const positions = useMemo(
    () => [
      { x: 0, y: 0, rotate: 0 },
      { x: 30, y: -40, rotate: 5 },
      { x: -35, y: 35, rotate: -6 },
      { x: 85, y: -20, rotate: 3 },
      { x: -70, y: 55, rotate: -4 },
      { x: 100, y: -60, rotate: 4 },
      { x: -25, y: 20, rotate: -3 },
      { x: 0, y: 0, rotate: 0 },
    ],
    []
  );

  const fallbackPosition = useMemo(() => ({ x: 0, y: 0, rotate: 0 }), []);

  const getNotificationPosition = useCallback(
    (index: number) => positions[index] || fallbackPosition,
    [fallbackPosition, positions]
  );

  return (
    <div
      className={cn(
        "relative flex items-center justify-center min-h-[400px] w-full  rounded-xl overflow-hidden",
        className
      )}
    >
      <div className="relative w-full max-w-4xl h-[400px]">
        <AnimatePresence>
          {isMounted &&
            visibleNotifications.map((notification, index) => {
              const position = getNotificationPosition(index);
              const IconComponent = notification.icon;

              return (
                <motion.div
                  key={`${animationCycle}-${notification.id}-${index}`}
                  className="absolute left-1/2 top-1/2 w-52 md:w-80"
                  initial={{
                    opacity: 0,
                    scale: 0.5,
                    x: "-50%",
                    y: "-50%",
                    rotate: 0,
                  }}
                  animate={{
                    opacity: animationPhase === "fading" ? 0 : 1,
                    scale: animationPhase === "fading" ? 0.5 : 1,
                    x: `calc(-50% + ${position.x}px)`,
                    y: `calc(-50% + ${position.y}px)`,
                    rotate: position.rotate,
                  }}
                  exit={{
                    opacity: 0,
                    scale: 0.5,
                    transition: { duration: 0.4, ease: "easeIn" },
                  }}
                  transition={{
                    duration: 0.8,
                    ease: [0.2, 0.8, 0.2, 1],
                    delay: 0,
                  }}
                  style={{
                    zIndex: index + 1,
                    willChange: "transform, opacity",
                  }}
                >
                  <div className="relative overflow-hidden rounded-lg md:rounded-2xl border border-neutral-300/70 dark:border-white/20 shadow-2xl shadow-white/70 dark:shadow-black/30 backdrop-blur-2xl bg-white/80 dark:bg-neutral-900/80">
                    <div className="absolute inset-0 bg-gradient-to-br from-white/50 to-transparent dark:from-white/5 dark:to-transparent" />
                    <div className="relative p-3 md:p-4">
                      <div className=" gap-2 md:gap-3">
                        <div className="flex-shrink-0 flex">
                          <div className="flex items-center">
                            <div
                              className={cn(
                                "md:w-5 md:h-5 w-3.5 h-3.5 rounded-sm md:rounded-md flex items-center relative justify-center shadow-lg",
                                notification.color
                              )}
                            >
                              {/* <FaBell className="w-4 h-4 text-yellow-400 absolute -top-2 -left-2" /> */}
                              <IconComponent className="md:w-3 md:h-3 h-2 w-2 text-white" />
                            </div>
                            <span className="font-semibold ml-2 my-auto text-neutral-900 dark:text-white text-xs md:text-sm mb-0.5 truncate">
                              {notification.title}
                            </span>
                          </div>

                          <div className="ml-auto text-xs text-neutral-500 dark:text-neutral-400">
                            {notification.time}
                          </div>
                        </div>
                        <div className="flex-1 min-w-0 md:pt-0.5">
                          <p className="text-[10px] md:text-xs text-neutral-700 dark:text-neutral-300 line-clamp-2 leading-relaxed">
                            {notification.subtitle}
                          </p>
                        </div>
                      </div>
                    </div>
                  </div>
                </motion.div>
              );
            })}
        </AnimatePresence>
      </div>
    </div>
  );
}

Usage

import SpamNotifications from "@/components/spam-notifications";

export default function Example() {
  return (
    <div className="w-full h-screen">
      <SpamNotifications />
    </div>
  );
}

Custom Messages

You can customize the notification messages by modifying the ALL_NOTIFICATION_MESSAGES array:

const ALL_NOTIFICATION_MESSAGES = [
  {
    id: "1",
    icon: Gift, // Any lucide-react icon
    title: "Custom Title",
    time: "just now",
    subtitle: "Your custom notification message here",
    color: "bg-blue-500/90", // Tailwind color with opacity
  },
  // Add more messages...
];

Props

PropTypeDefaultDescription
classNamestringundefinedAdditional CSS classes for the container