"use client";
import {
useEffect,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
type ReactNode,
} from "react";
import { AnimatePresence, motion, useMotionValue, useSpring } from "motion/react";
export interface MagneticButtonProps {
children?: ReactNode;
pullStrength?: number;
radius?: number;
stiffness?: number;
damping?: number;
auraColor?: string;
auraSize?: number;
ripple?: boolean;
rippleColor?: string;
reducedMotion?: boolean;
className?: string;
}
interface Ripple {
id: number;
x: number;
y: number;
size: number;
}
export default function MagneticButton({
children = "Magnetic",
pullStrength = 0.4,
radius = 120,
stiffness = 260,
damping = 18,
auraColor = "#a855f7",
auraSize = 80,
ripple = true,
rippleColor = "#d2abfd",
reducedMotion = false,
className,
}: MagneticButtonProps) {
const buttonRef = useRef<HTMLButtonElement>(null);
const rippleId = useRef(0);
const [ripples, setRipples] = useState<Ripple[]>([]);
const [coarse, setCoarse] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(hover: none), (pointer: coarse)");
const update = () => setCoarse(mq.matches);
update();
mq.addEventListener("change", update);
return () => mq.removeEventListener("change", update);
}, []);
const magnetic = !reducedMotion && !coarse;
const spring = { stiffness, damping } as const;
const x = useSpring(0, spring);
const y = useSpring(0, spring);
const auraOpacity = useSpring(reducedMotion ? 0.35 : 0, spring);
useEffect(() => {
auraOpacity.set(reducedMotion ? 0.35 : 0);
if (reducedMotion) {
x.set(0);
y.set(0);
}
}, [reducedMotion, auraOpacity, x, y]);
const handlePointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
if (!magnetic) return;
const el = buttonRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
const dx = e.clientX - cx;
const dy = e.clientY - cy;
const dist = Math.hypot(dx, dy);
if (dist < radius) {
x.set(dx * pullStrength);
y.set(dy * pullStrength);
auraOpacity.set(1 - dist / radius);
} else {
x.set(0);
y.set(0);
auraOpacity.set(0);
}
};
const handlePointerLeave = () => {
if (!magnetic) return;
x.set(0);
y.set(0);
auraOpacity.set(0);
};
const handleClick = (e: ReactPointerEvent<HTMLButtonElement>) => {
if (!ripple || reducedMotion) return;
const el = buttonRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const localX = e.clientX - rect.left;
const localY = e.clientY - rect.top;
const size =
2 *
Math.max(
Math.hypot(localX, localY),
Math.hypot(rect.width - localX, localY),
Math.hypot(localX, rect.height - localY),
Math.hypot(rect.width - localX, rect.height - localY),
);
const id = rippleId.current++;
setRipples((prev) => [...prev, { id, x: localX, y: localY, size }]);
};
const removeRipple = (id: number) =>
setRipples((prev) => prev.filter((r) => r.id !== id));
return (
<div
onPointerMove={handlePointerMove}
onPointerLeave={handlePointerLeave}
style={{ padding: magnetic ? radius : 0 }}
className="relative inline-flex max-w-full items-center justify-center"
>
<div className="relative inline-flex">
<motion.span
aria-hidden
className="pointer-events-none absolute left-1/2 top-1/2 -z-10 rounded-full"
style={{
x,
y,
opacity: auraOpacity,
width: `calc(100% + ${auraSize * 2}px)`,
height: `calc(100% + ${auraSize * 2}px)`,
translateX: "-50%",
translateY: "-50%",
background: `radial-gradient(circle at center, ${auraColor}, transparent 70%)`,
}}
/>
<motion.button
ref={buttonRef}
type="button"
onPointerDown={handleClick}
style={{ touchAction: "manipulation", ...(magnetic ? { x, y } : {}) }}
className={
className ??
"relative isolate cursor-pointer overflow-hidden rounded-md border border-hairline bg-panel px-6 py-3 font-display text-sm uppercase tracking-widest text-ink transition-colors hover:text-accent"
}
>
<AnimatePresence>
{ripples.map((r) => (
<motion.span
key={r.id}
aria-hidden
className="pointer-events-none absolute rounded-full"
style={{
left: r.x,
top: r.y,
width: r.size,
height: r.size,
marginLeft: -r.size / 2,
marginTop: -r.size / 2,
background: rippleColor,
}}
initial={{ scale: 0, opacity: 0.5 }}
animate={{ scale: 1, opacity: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.6, ease: "easeOut" }}
onAnimationComplete={() => removeRipple(r.id)}
/>
))}
</AnimatePresence>
<span className="relative z-10">{children}</span>
</motion.button>
</div>
</div>
);
}