"use client"; import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { motion, useReducedMotion, useSpring, useTransform } from "motion/react"; type MagneticCursorProps = { /** The area that gets the custom cursor. */ children: ReactNode; /** What the ring wraps around. */ selector?: string; /** Ring size in pixels when it is not on a target. */ size?: number; /** Pixels between the ring and the target it wraps. */ padding?: number; /** How far the ring leans toward the pointer on a target, 0 to 1. */ pull?: number; /** Ring colour. */ accent?: string; className?: string; }; const SPRING = { stiffness: 420, damping: 34, mass: 0.6 }; /* MagneticCursor: a ring replaces the mouse cursor. Over a link or button it snaps on and takes the shape of that element. */ export function MagneticCursor({ children, selector = "a, button, [data-magnetic]", size = 28, padding = 8, pull = 0.2, accent = "#a78bfa", className = "", }: MagneticCursorProps) { const reduce = useReducedMotion(); const zone = useRef(null); const last = useRef<{ x: number; y: number } | null>(null); const [shown, setShown] = useState(false); const x = useSpring(0, SPRING); const y = useSpring(0, SPRING); const width = useSpring(size, SPRING); const height = useSpring(size, SPRING); const radius = useSpring(size / 2, SPRING); const left = useTransform([x, width], ([a, b]: number[]) => a - b / 2); const top = useTransform([y, height], ([a, b]: number[]) => a - b / 2); const place = useCallback( (cx: number, cy: number) => { const box = zone.current; if (!box) return; const r = box.getBoundingClientRect(); const hit = document.elementFromPoint(cx, cy)?.closest(selector); if (hit && box.contains(hit)) { const t = hit.getBoundingClientRect(); const mx = t.left + t.width / 2; const my = t.top + t.height / 2; x.set(mx + (cx - mx) * pull - r.left); y.set(my + (cy - my) * pull - r.top); width.set(t.width + padding * 2); height.set(t.height + padding * 2); // rounded-full computes to a huge number, so cap it at a pill. radius.set(Math.min(parseFloat(getComputedStyle(hit).borderTopLeftRadius) + padding, t.height / 2 + padding)); } else { x.set(cx - r.left); y.set(cy - r.top); width.set(size); height.set(size); radius.set(size / 2); } }, [selector, pull, padding, size, x, y, width, height, radius], ); // A wheel scroll moves the page under a still mouse. useEffect(() => { if (!shown) return; const onScroll = () => last.current && place(last.current.x, last.current.y); window.addEventListener("scroll", onScroll, { capture: true, passive: true }); return () => window.removeEventListener("scroll", onScroll, { capture: true }); }, [shown, place]); const onMove = (e: React.PointerEvent) => { if (reduce || e.pointerType !== "mouse") return; if (!last.current) { const r = e.currentTarget.getBoundingClientRect(); x.jump(e.clientX - r.left); y.jump(e.clientY - r.top); setShown(true); } last.current = { x: e.clientX, y: e.clientY }; place(e.clientX, e.clientY); }; const onLeave = () => { last.current = null; setShown(false); }; return (
{children}
); }