File size: 3,374 Bytes
38bc0dc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
import { useRef, useState, type ReactNode, type MouseEvent } from "react";
import { motion, AnimatePresence } from "framer-motion";

interface MagneticButtonProps {
  children: ReactNode;
  onClick?: () => void;
  disabled?: boolean;
  variant?: "copper" | "ember" | "titanium";
}

const VARIANT_COLORS = {
  copper: { color: "var(--copper)", glow: "var(--copper-glow)" },
  ember: { color: "var(--ember)", glow: "var(--ember-glow)" },
  titanium: { color: "var(--titanium)", glow: "var(--titanium-glow)" },
} as const;

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

export default function MagneticButton({
  children,
  onClick,
  disabled,
  variant = "copper",
}: MagneticButtonProps) {
  const ref = useRef<HTMLButtonElement>(null);
  const [offset, setOffset] = useState({ x: 0, y: 0 });
  const [hovered, setHovered] = useState(false);
  const [ripples, setRipples] = useState<Ripple[]>([]);
  const rippleId = useRef(0);

  function handleMouseMove(e: MouseEvent<HTMLButtonElement>) {
    const el = ref.current;
    if (!el) return;
    const rect = el.getBoundingClientRect();
    const relX = e.clientX - rect.left - rect.width / 2;
    const relY = e.clientY - rect.top - rect.height / 2;
    setOffset({ x: relX * 0.3, y: relY * 0.45 });
  }

  function handleMouseLeave() {
    setOffset({ x: 0, y: 0 });
    setHovered(false);
  }

  function handleClick(e: MouseEvent<HTMLButtonElement>) {
    const el = ref.current;
    if (el) {
      const rect = el.getBoundingClientRect();
      const ripple = { id: rippleId.current++, x: e.clientX - rect.left, y: e.clientY - rect.top };
      setRipples((r) => [...r, ripple]);
      setTimeout(() => setRipples((r) => r.filter((rp) => rp.id !== ripple.id)), 700);
    }
    onClick?.();
  }

  const { color, glow } = VARIANT_COLORS[variant];

  return (
    <motion.button
      ref={ref}
      className="magnetic-button"
      onMouseMove={handleMouseMove}
      onMouseEnter={() => setHovered(true)}
      onMouseLeave={handleMouseLeave}
      onClick={handleClick}
      disabled={disabled}
      animate={{ x: offset.x, y: offset.y, scale: hovered && !disabled ? 1.04 : 1 }}
      whileTap={{ scale: 0.96 }}
      transition={{ type: "spring", stiffness: 260, damping: 14, mass: 0.4 }}
      style={{ color, boxShadow: hovered && !disabled ? `0 0 28px ${glow}` : `0 0 12px ${glow}` }}
    >
      {/* animated border trace */}
      <svg className="button-trace" aria-hidden>
        <motion.rect
          x={1.5}
          y={1.5}
          rx="24"
          fill="none"
          className="trace-rect"
          stroke={color}
          strokeWidth="1.5"
          initial={false}
          animate={{ pathLength: hovered && !disabled ? 1 : 0.08, opacity: disabled ? 0.3 : 1 }}
          transition={{ duration: 0.55, ease: "easeInOut" }}
        />
      </svg>

      <AnimatePresence>
        {ripples.map((r) => (
          <motion.span
            key={r.id}
            className="button-ripple"
            style={{ left: r.x, top: r.y, background: glow }}
            initial={{ scale: 0, opacity: 0.7 }}
            animate={{ scale: 5, opacity: 0 }}
            exit={{ opacity: 0 }}
            transition={{ duration: 0.65, ease: "easeOut" }}
          />
        ))}
      </AnimatePresence>

      <span className="button-label">{children}</span>
    </motion.button>
  );
}