Buttons

Magnetic Ripple Button

An interactive React CTA button featuring magnetic cursor tracking, dynamic spotlight glow, and expanding click ripple animation.

ReactavailableIntermediate
buttonmagneticrippleframer-motioncta
Author: Vijay Kumar @Vijaykumar5623
Live React preview
Loading preview…
"use client";

import React, { useState, useRef, useCallback, useEffect } from "react";
import { motion, useSpring, useMotionValue, useReducedMotion, type HTMLMotionProps } from "framer-motion";

export interface MagneticRippleButtonProps extends HTMLMotionProps<"button"> {
  children?: React.ReactNode;
  className?: string;
  variant?: "primary" | "secondary" | "outline" | "ghost";
  size?: "sm" | "md" | "lg";
  magneticStrength?: number;
  rippleColor?: string;
}

interface Ripple {
  id: number;
  x: number;
  y: number;
  size: number;
}

export default function MagneticRippleButton({
  children,
  className = "",
  variant = "primary",
  size = "md",
  magneticStrength = 0.35,
  rippleColor = "rgba(255, 255, 255, 0.4)",
  onClick,
  ...props
}: MagneticRippleButtonProps) {
  const buttonRef = useRef<HTMLButtonElement>(null);
  const [ripples, setRipples] = useState<Ripple[]>([]);
  const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
  const [isHovered, setIsHovered] = useState(false);

  // Honor prefers-reduced-motion: skip the magnetic pull, spotlight glow,
  // ripple, and tap-scale for users who ask for reduced motion (WCAG 2.3.3).
  const prefersReducedMotion = useReducedMotion();

  // Motion values for magnetic pull physics
  const rawX = useMotionValue(0);
  const rawY = useMotionValue(0);

  const springConfig = { damping: 15, stiffness: 150, mass: 0.1 };
  const magneticX = useSpring(rawX, springConfig);
  const magneticY = useSpring(rawY, springConfig);

  // If reduced motion turns on mid-interaction, snap the magnetic offset back
  // to rest instead of leaving the button displaced until the next mouseleave.
  useEffect(() => {
    if (prefersReducedMotion) {
      rawX.set(0);
      rawY.set(0);
    }
  }, [prefersReducedMotion, rawX, rawY]);

  const handleMouseMove = useCallback(
    (e: React.MouseEvent<HTMLButtonElement>) => {
      if (!buttonRef.current || prefersReducedMotion) return;
      const rect = buttonRef.current.getBoundingClientRect();
      const centerX = rect.left + rect.width / 2;
      const centerY = rect.top + rect.height / 2;

      const offsetX = (e.clientX - centerX) * magneticStrength;
      const offsetY = (e.clientY - centerY) * magneticStrength;

      rawX.set(offsetX);
      rawY.set(offsetY);

      setMousePos({
        x: e.clientX - rect.left,
        y: e.clientY - rect.top,
      });
    },
    [magneticStrength, rawX, rawY, prefersReducedMotion]
  );

  const handleMouseEnter = useCallback(() => {
    setIsHovered(true);
  }, []);

  const handleMouseLeave = useCallback(() => {
    setIsHovered(false);
    rawX.set(0);
    rawY.set(0);
  }, [rawX, rawY]);

  const handleClick = useCallback(
    (e: React.MouseEvent<HTMLButtonElement>) => {
      if (buttonRef.current && !prefersReducedMotion) {
        const rect = buttonRef.current.getBoundingClientRect();
        const size = Math.max(rect.width, rect.height) * 2;
        const x = e.clientX - rect.left;
        const y = e.clientY - rect.top;

        const newRipple: Ripple = {
          id: Date.now(),
          x,
          y,
          size,
        };

        setRipples((prev) => [...prev, newRipple]);

        setTimeout(() => {
          setRipples((prev) => prev.filter((r) => r.id !== newRipple.id));
        }, 600);
      }

      if (onClick) {
        onClick(e);
      }
    },
    [onClick, prefersReducedMotion]
  );

  // Variant styling
  const variantStyles = {
    primary:
      "bg-gradient-to-r from-violet-600 via-indigo-600 to-purple-600 text-white shadow-lg shadow-indigo-500/25 hover:shadow-indigo-500/40 border border-violet-500/30",
    secondary:
      "bg-slate-900 text-slate-100 border border-slate-700/80 hover:bg-slate-800 shadow-md shadow-black/40",
    outline:
      "bg-transparent text-indigo-400 border-2 border-indigo-500/60 hover:bg-indigo-500/10 hover:border-indigo-400",
    ghost:
      "bg-transparent text-slate-200 hover:bg-slate-800/60 hover:text-white",
  };

  // Size styling
  const sizeStyles = {
    sm: "px-4 py-2 text-xs font-semibold gap-1.5 rounded-lg",
    md: "px-6 py-3 text-sm font-semibold gap-2 rounded-xl",
    lg: "px-8 py-4 text-base font-semibold gap-2.5 rounded-2xl",
  };

  return (
    <motion.button
      ref={buttonRef}
      style={{ x: magneticX, y: magneticY }}
      onMouseMove={handleMouseMove}
      onMouseEnter={handleMouseEnter}
      onMouseLeave={handleMouseLeave}
      onClick={handleClick}
      whileTap={prefersReducedMotion ? undefined : { scale: 0.96 }}
      className={`relative inline-flex items-center justify-center overflow-hidden cursor-pointer select-none transition-colors duration-200 outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950 ${variantStyles[variant]} ${sizeStyles[size]} ${className}`}
      {...props}
    >
      {/* Interactive Radial Spotlight Glow */}
      {isHovered && !prefersReducedMotion && (
        <span
          className="pointer-events-none absolute -translate-x-1/2 -translate-y-1/2 rounded-full blur-xl transition-opacity duration-300"
          style={{
            left: `${mousePos.x}px`,
            top: `${mousePos.y}px`,
            width: "120px",
            height: "120px",
            background:
              variant === "primary"
                ? "radial-gradient(circle, rgba(255,255,255,0.3) 0%, transparent 70%)"
                : "radial-gradient(circle, rgba(99,102,241,0.35) 0%, transparent 70%)",
          }}
        />
      )}

      {/* Button Content */}
      <span className="relative z-10 flex items-center gap-2">{children}</span>

      {/* Expanding Ripple Elements */}
      {ripples.map((ripple) => (
        <span
          key={ripple.id}
          className="pointer-events-none absolute rounded-full animate-ripple"
          style={{
            left: `${ripple.x}px`,
            top: `${ripple.y}px`,
            width: `${ripple.size}px`,
            height: `${ripple.size}px`,
            backgroundColor: rippleColor,
            transform: "translate(-50%, -50%) scale(0)",
          }}
        />
      ))}

      {/* Ripple Animation CSS inline scope */}
      <style jsx>{`
        @keyframes ripple {
          0% {
            transform: translate(-50%, -50%) scale(0);
            opacity: 0.7;
          }
          100% {
            transform: translate(-50%, -50%) scale(1);
            opacity: 0;
          }
        }
        .animate-ripple {
          animation: ripple 600ms cubic-bezier(0.1, 0.8, 0.3, 1) forwards;
        }
      `}</style>
    </motion.button>
  );
}