CappyUI LogoCappyUI

Wave Effect Card

An animated card grid component featuring a ripple wave effect that emanates from the center, creating a dynamic opacity reveal animation across button-style cards of varying widths.

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/wave-effect-card.json

Install dependencies

npm i 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/ui/wave-effect-card.tsx

src/components/components/wave-effect-card.tsx
"use client";

import React, {
  memo,
  useCallback,
  useEffect,
  useRef,
  useState,
  useMemo,
} from "react";
import { motion, AnimatePresence } from "framer-motion";
import { cn } from "@/lib/utils";
import { HiMiniLockClosed } from "react-icons/hi2";

interface CardItem {
  id: number;
  label: string;
  width: "sm" | "md" | "lg" | "xl";
}

interface CardPosition {
  x: number;
  y: number;
  width: number;
  height: number;
}

interface WaveEffectCardProps {
  items?: string[];
  waveSpeed?: number;
  waveThickness?: number;
  fadeWidth?: number;
  baseOpacity?: number;
  activeOpacity?: number;
  className?: string;
  title?: string;
  subtitle?: string;
  description?: string;
  icon?: React.ReactNode;
  glowDuration?: number;
}

const SECURITY_WORDS = [
  "Encrypt", "Shield", "Guard", "Secure", "Protect",
  "Private", "Safety", "Locked", "Cipher", "Verify",
  "Trust", "Defense", "Firewall", "Auth", "Token",
  "Hash", "SSL", "VPN", "2FA", "Biometric",
  "Access", "Vault", "Key", "Secret", "Stealth",
  "Armor", "Sentinel", "Fortress", "Barrier", "Filter",
  "Sandbox", "Proxy", "Audit", "Comply", "GDPR",
  "Zero-Trust", "E2E", "AES", "RSA", "HTTPS",
];

const DEFAULT_ITEMS = Array(80)
  .fill(null)
  .map((_, i) => SECURITY_WORDS[i % SECURITY_WORDS.length]);

const WIDTH_CLASSES: Record<CardItem["width"], string> = {
  sm: "w-14 sm:w-16",
  md: "w-18 sm:w-20",
  lg: "w-22 sm:w-24",
  xl: "w-26 sm:w-28",
};

const WIDTH_PATTERN: CardItem["width"][] = [
  "md", "lg", "sm", "xl", "md", "sm", "lg", "md", "xl", "sm",
  "lg", "md", "sm", "xl", "lg", "md", "sm", "lg", "lg", "md",
  "sm", "xl", "lg", "md", "sm", "lg", "md", "md", "xl", "sm",
  "lg", "md", "sm", "xl", "lg", "md", "sm", "lg", "xl", "md",
  "sm", "lg", "md", "xl", "sm", "md", "lg", "sm", "xl", "md",
];

// Smooth easing function for wave transitions
const easeInOutSine = (t: number): number => {
  return -(Math.cos(Math.PI * t) - 1) / 2;
};

const calculateOpacity = (
  distance: number,
  innerRadius: number,
  outerRadius: number,
  fadeWidth: number,
  baseOpacity: number,
  activeOpacity: number
): number => {
  const innerFadeStart = innerRadius - fadeWidth;
  const outerFadeEnd = outerRadius + fadeWidth;

  if (distance < innerFadeStart || distance > outerFadeEnd) {
    return baseOpacity;
  }

  if (distance >= innerRadius && distance <= outerRadius) {
    return activeOpacity;
  }

  if (distance >= innerFadeStart && distance < innerRadius) {
    const t = (distance - innerFadeStart) / fadeWidth;
    const easedT = easeInOutSine(t);
    return baseOpacity + (activeOpacity - baseOpacity) * easedT;
  }

  if (distance > outerRadius && distance <= outerFadeEnd) {
    const t = (distance - outerRadius) / fadeWidth;
    const easedT = easeInOutSine(t);
    return activeOpacity - (activeOpacity - baseOpacity) * easedT;
  }

  return baseOpacity;
};

const Card = memo(
  ({
    item,
    opacity,
    cardRef,
  }: {
    item: CardItem;
    opacity: number;
    cardRef: (el: HTMLDivElement | null) => void;
  }) => (
    <div
      ref={cardRef}
      className={cn(
        "h-7 sm:h-8 rounded-md border border-neutral-200/80 dark:border-neutral-600/90",
        "bg-neutral-200/50 dark:bg-neutral-800/80 flex items-center justify-center",
        "text-xs font-mono font-medium text-neutral-500 dark:text-neutral-400",
        "transition-opacity duration-100",
        WIDTH_CLASSES[item.width]
      )}
      style={{ opacity }}
    >
      <span className="truncate px-1.5">{item.label}</span>
    </div>
  )
);

Card.displayName = "Card";

type AnimationPhase = "charging" | "pulse" | "wave" | "idle";

export const WaveEffectCard = memo(
  ({
    items = DEFAULT_ITEMS,
    waveSpeed = 100,
    waveThickness = 80,
    fadeWidth = 50,
    baseOpacity = 0.2,
    activeOpacity = 0.75,
    className,
    title = "Enterprise-grade security.",
    subtitle = "Your data is protected with military-grade encryption.",
    description = "End-to-end secured, always.",
    icon,
    glowDuration = 0.6,
  }: WaveEffectCardProps) => {
    const containerRef = useRef<HTMLDivElement>(null);
    const cardRefsMap = useRef<Map<number, HTMLDivElement>>(new Map());
    const animationFrameRef = useRef<number | null>(null);
    const startTimeRef = useRef<number | null>(null);

    const [cardOpacities, setCardOpacities] = useState<Map<number, number>>(new Map());
    const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
    const [animationPhase, setAnimationPhase] = useState<AnimationPhase>("charging");
    const [rippleKey, setRippleKey] = useState(0);

    const cardItems = useMemo<CardItem[]>(
      () =>
        items.map((label, index) => ({
          id: index,
          label,
          width: WIDTH_PATTERN[index % WIDTH_PATTERN.length],
        })),
      [items]
    );

    const maxRadius = useMemo(() => {
      return Math.sqrt(
        Math.pow(containerSize.width / 2, 2) +
        Math.pow(containerSize.height / 2, 2)
      );
    }, [containerSize]);

    const getCardPosition = useCallback((id: number): CardPosition | null => {
      const card = cardRefsMap.current.get(id);
      const container = containerRef.current;
      if (!card || !container) return null;

      const cardRect = card.getBoundingClientRect();
      const containerRect = container.getBoundingClientRect();

      return {
        x: cardRect.left - containerRect.left + cardRect.width / 2,
        y: cardRect.top - containerRect.top + cardRect.height / 2,
        width: cardRect.width,
        height: cardRect.height,
      };
    }, []);

    // Wave animation loop
    const animate = useCallback(
      (timestamp: number) => {
        if (startTimeRef.current === null) {
          startTimeRef.current = timestamp;
        }

        const elapsed = timestamp - startTimeRef.current;
        const currentRadius = (elapsed * waveSpeed) / 1000;
        const totalCycleDistance = maxRadius + waveThickness + fadeWidth * 2;

        // Check if wave cycle completed
        if (currentRadius >= totalCycleDistance) {
          // Wave complete, reset all opacities to base and transition to charging
          const resetOpacities = new Map<number, number>();
          cardItems.forEach((item) => {
            resetOpacities.set(item.id, baseOpacity);
          });
          setCardOpacities(resetOpacities);

          // Cancel animation and transition phase
          if (animationFrameRef.current !== null) {
            cancelAnimationFrame(animationFrameRef.current);
            animationFrameRef.current = null;
          }
          startTimeRef.current = null;
          setAnimationPhase("charging");
          return;
        }

        const innerRadius = Math.max(0, currentRadius - waveThickness);
        const outerRadius = currentRadius;

        const centerX = containerSize.width / 2;
        const centerY = containerSize.height / 2;

        const newOpacities = new Map<number, number>();

        cardItems.forEach((item) => {
          const position = getCardPosition(item.id);
          if (position) {
            const distance = Math.sqrt(
              Math.pow(position.x - centerX, 2) +
              Math.pow(position.y - centerY, 2)
            );

            const opacity = calculateOpacity(
              distance,
              innerRadius,
              outerRadius,
              fadeWidth,
              baseOpacity,
              activeOpacity
            );

            newOpacities.set(item.id, opacity);
          } else {
            newOpacities.set(item.id, baseOpacity);
          }
        });

        setCardOpacities(newOpacities);
        animationFrameRef.current = requestAnimationFrame(animate);
      },
      [
        cardItems,
        containerSize,
        maxRadius,
        waveSpeed,
        waveThickness,
        fadeWidth,
        baseOpacity,
        activeOpacity,
        getCardPosition,
      ]
    );

    // Handle phase transitions - charging builds up, pulse releases, then wave starts
    const handleChargingComplete = useCallback(() => {
      if (animationPhase === "charging") {
        setAnimationPhase("pulse");
        setRippleKey((prev) => prev + 1);
      }
    }, [animationPhase]);

    const handlePulseComplete = useCallback(() => {
      if (animationPhase === "pulse") {
        setAnimationPhase("wave");
      }
    }, [animationPhase]);

    // Start wave animation when phase is "wave"
    useEffect(() => {
      if (animationPhase === "wave" && containerSize.width > 0 && containerSize.height > 0) {
        startTimeRef.current = null;
        animationFrameRef.current = requestAnimationFrame(animate);
      }

      return () => {
        if (animationFrameRef.current !== null) {
          cancelAnimationFrame(animationFrameRef.current);
          animationFrameRef.current = null;
        }
      };
    }, [animationPhase, animate, containerSize]);

    // Measure container size
    useEffect(() => {
      const container = containerRef.current;
      if (!container) return;

      const resizeObserver = new ResizeObserver((entries) => {
        const entry = entries[0];
        if (entry) {
          setContainerSize({
            width: entry.contentRect.width,
            height: entry.contentRect.height,
          });
        }
      });

      resizeObserver.observe(container);

      return () => {
        resizeObserver.disconnect();
      };
    }, []);

    const setCardRef = useCallback(
      (id: number) => (el: HTMLDivElement | null) => {
        if (el) {
          cardRefsMap.current.set(id, el);
        } else {
          cardRefsMap.current.delete(id);
        }
      },
      []
    );

    // Icon button animation variants - charging builds energy, pulse releases it
    const iconButtonVariants = {
      idle: {
        scale: 1,
        boxShadow: "0 0 0px rgba(59, 130, 246, 0)",
      },
      charging: {
        scale: [1, 1.1, 1.08],
        boxShadow: [
          "0 0 0px rgba(59, 130, 246, 0)",
          "0 0 35px rgba(59, 130, 246, 0.4), 0 0 70px rgba(59, 130, 246, 0.2)",
          "0 0 45px rgba(59, 130, 246, 0.5), 0 0 90px rgba(59, 130, 246, 0.25)",
        ],
      },
      pulse: {
        scale: [1.08, 0.92, 1],
        boxShadow: [
          "0 0 45px rgba(59, 130, 246, 0.5), 0 0 90px rgba(59, 130, 246, 0.25)",
          "0 0 15px rgba(59, 130, 246, 0.2)",
          "0 0 0px rgba(59, 130, 246, 0)",
        ],
      },
    };

    const getIconAnimationState = () => {
      if (animationPhase === "charging") return "charging";
      if (animationPhase === "pulse") return "pulse";
      return "idle";
    };

    return (
      <div className="max-w-xl relative mx-auto h-96 overflow-hidden rounded-2xl border border-neutral-200 dark:border-neutral-900 bg-white dark:bg-neutral-950 shadow-sm">
        <div
          ref={containerRef}
          className={cn(
            "flex absolute -top-8 -left-8 w-[calc(100%+4rem)] flex-wrap gap-2 justify-center items-center p-4 select-none pointer-events-none",
            "opacity-50 [mask-image:radial-gradient(ellipse_at_center,black_30%,transparent_70%)]",
            className
          )}
        >
          {cardItems.map((item) => (
            <Card
              key={item.id}
              item={item}
              opacity={cardOpacities.get(item.id) ?? baseOpacity}
              cardRef={setCardRef(item.id)}
            />
          ))}
        </div>

        {/* Center content overlay */}
        <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 flex flex-col space-y-4 items-center justify-center pointer-events-none z-10">
          <div className="relative">
            {/* Ripple div that emanates from button with radial fade */}
            <AnimatePresence>
              {(animationPhase === "pulse" || animationPhase === "wave") && (
                <motion.div
                  key={rippleKey}
                  className="absolute inset-0 rounded-full"
                  initial={{ scale: 1, opacity: 0.5 }}
                  animate={{
                    scale: 8,
                    opacity: 0,
                  }}
                  transition={{
                    duration: 1.2,
                    ease: "easeOut",
                  }}
                  style={{
                    transformOrigin: "center",
                    background: "radial-gradient(circle, rgba(59, 130, 246, 0.4) 0%, rgba(59, 130, 246, 0) 70%)",
                  }}
                />
              )}
            </AnimatePresence>

            {/* Main icon button */}
            <motion.div
              className={cn(
                "relative rounded-2xl p-4",
                "bg-gradient-to-br from-white to-neutral-200 dark:from-neutral-800 dark:to-neutral-900",
                "border-t border-l border-white outline-2 outline-neutral-200/50 dark:outline-neutral-800 dark:border-neutral-600/80",
                "shadow-xl shadow-blue-500/10"
              )}
              variants={iconButtonVariants}
              initial="idle"
              animate={getIconAnimationState()}
              transition={{
                duration: animationPhase === "charging" ? glowDuration : 0.35,
                ease: animationPhase === "charging" ? [0.4, 0, 0.2, 1] : [0.25, 0.1, 0.25, 1],
                times: animationPhase === "charging" ? [0, 0.5, 1] : [0, 0.3, 1],
              }}
              onAnimationComplete={
                animationPhase === "charging"
                  ? handleChargingComplete
                  : animationPhase === "pulse"
                    ? handlePulseComplete
                    : undefined
              }
            >
              {icon || <HiMiniLockClosed className="w-8 h-8 text-blue-500 dark:text-blue-500" />}
            </motion.div>
          </div>
          <div className="flex flex-col items-center space-y-2">
            <span className="text-2xl sm:text-2xl font-bold tracking-tight text-neutral-900 dark:text-white text-center">
              {title}
            </span>
            <span className="text-sm sm:text-sm text-neutral-600 dark:text-neutral-400 text-center max-w-sm leading-relaxed">
              {subtitle} {description}
            </span>
          </div>
        </div>
      </div>
    );
  }
);

WaveEffectCard.displayName = "WaveEffectCard";

export default WaveEffectCard;

Usage

import { WaveEffectCard } from "@/components/ui/wave-effect-card";

export default function Page() {
  return <WaveEffectCard />;
}

Customization

Custom Card Labels

Provide your own array of card labels:

<WaveEffectCard
  items={[
    "Button 1",
    "Action",
    "Settings",
    "Profile",
    "Dashboard",
    "Analytics",
    "Reports",
    "Users",
  ]}
/>

Wave Animation Properties

Customize the wave animation behavior:

<WaveEffectCard
  waveSpeed={200} // Pixels per second (default: 150)
  waveThickness={120} // Ring thickness in pixels (default: 100)
  fadeWidth={60} // Fade effect width on ring edges (default: 50)
/>

Opacity Settings

Adjust the base and active opacity levels:

<WaveEffectCard
  baseOpacity={0.3} // Opacity when not in wave (default: 0.4)
  activeOpacity={1} // Opacity when in wave (default: 1)
/>

Center Content

Customize the center overlay with title, subtitle, description, and icon:

<WaveEffectCard
  title="We're serious about privacy."
  subtitle="Your data is built with privacy at its core."
  description="We never train on your data."
  icon={<YourCustomIcon />}
/>

Props

PropTypeDefaultDescription
itemsstring[]80 "Ripple" labelsArray of card labels
waveSpeednumber120Wave animation speed in pixels per second
waveThicknessnumber60Thickness of the wave ring in pixels
fadeWidthnumber30Width of the fade effect on ring edges
baseOpacitynumber0.4Card opacity when outside the wave
activeOpacitynumber1Card opacity when inside the wave ring
titlestring"We're serious about privacy."Main heading text in center overlay
subtitlestring"Your data is built with privacy..."Subtitle text in center overlay
descriptionstring"We never train on your data."Description text in center overlay
iconReact.ReactNodePrivacy lock iconCustom icon for center overlay
classNamestringundefinedAdditional CSS classes to apply to the container