A ring cursor that snaps and morphs to hug any [data-magnetic] target it hovers, with a center dot locked to the true pointer for precision.
npx shadcn@latest add @paragon/magnetic-cursor"use client";
import * as React from "react";
import {
motion,
useMotionValue,
useReducedMotion,
useSpring,
type SpringOptions,
} from "motion/react";
import { cn } from "@/lib/utils";
/**
* MagneticCursor — a ring cursor that free-floats over empty space, then snaps
* and expands to hug any `[data-magnetic]` target it hovers, morphing to the
* target's rounded rect. A center dot stays locked to the true pointer so
* precision is never lost. Perfect for hero CTAs and interactive galleries.
*
* Mark snap targets inside the surface with `data-magnetic` (any element).
* Native cursor hidden only within the surface; layer is pointer-events-none.
* Gated on fine-pointer devices. Reduced motion keeps the ring, drops the lag.
*/
export interface MagneticCursorProps extends React.ComponentProps<"div"> {
/** Ring + dot color. Defaults to the primary token. */
color?: string;
/** Idle ring diameter in px. */
ringSize?: number;
/** Ring stroke width in px. */
ringWidth?: number;
/** Extra padding added around a snapped target, in px. */
snapPadding?: number;
}
const RING_SPRING: SpringOptions = { stiffness: 320, damping: 30, mass: 0.7 };
const DOT_SPRING: SpringOptions = { stiffness: 700, damping: 34, mass: 0.4 };
interface Snap {
x: number;
y: number;
w: number;
h: number;
radius: number;
}
export function MagneticCursor({
color = "var(--color-primary)",
ringSize = 34,
ringWidth = 1.5,
snapPadding = 8,
className,
children,
...props
}: MagneticCursorProps) {
const hostRef = React.useRef<HTMLDivElement>(null);
const [fine, setFine] = React.useState(false);
const [inside, setInside] = React.useState(false);
const [snap, setSnap] = React.useState<Snap | null>(null);
const reduced = useReducedMotion();
// Ring geometry as motion values so snap/unsnap animate smoothly.
const rx = useMotionValue(-9999);
const ry = useMotionValue(-9999);
const rw = useMotionValue(ringSize);
const rh = useMotionValue(ringSize);
const rr = useMotionValue(ringSize / 2);
const ringX = useSpring(rx, RING_SPRING);
const ringY = useSpring(ry, RING_SPRING);
const ringW = useSpring(rw, RING_SPRING);
const ringH = useSpring(rh, RING_SPRING);
const ringR = useSpring(rr, RING_SPRING);
const dotX = useMotionValue(-9999);
const dotY = useMotionValue(-9999);
const dotSpringX = useSpring(dotX, DOT_SPRING);
const dotSpringY = useSpring(dotY, DOT_SPRING);
React.useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mql = window.matchMedia("(hover: hover) and (pointer: fine)");
const sync = () => setFine(mql.matches);
sync();
mql.addEventListener("change", sync);
return () => mql.removeEventListener("change", sync);
}, []);
React.useEffect(() => {
const el = hostRef.current;
if (!el || !fine) return;
const onMove = (e: PointerEvent) => {
const rect = el.getBoundingClientRect();
const px = e.clientX - rect.left;
const py = e.clientY - rect.top;
dotX.set(px);
dotY.set(py);
const target = (e.target as Element | null)?.closest?.(
"[data-magnetic]",
) as HTMLElement | null;
if (target && el.contains(target)) {
const tRect = target.getBoundingClientRect();
const cs = window.getComputedStyle(target);
const parsedRadius = parseFloat(cs.borderTopLeftRadius) || 0;
const w = tRect.width + snapPadding * 2;
const h = tRect.height + snapPadding * 2;
const next: Snap = {
x: tRect.left - rect.left + tRect.width / 2,
y: tRect.top - rect.top + tRect.height / 2,
w,
h,
radius: parsedRadius + snapPadding,
};
rx.set(next.x);
ry.set(next.y);
rw.set(next.w);
rh.set(next.h);
rr.set(next.radius);
setSnap(next);
} else {
rx.set(px);
ry.set(py);
rw.set(ringSize);
rh.set(ringSize);
rr.set(ringSize / 2);
setSnap(null);
}
};
const onEnter = () => setInside(true);
const onLeave = () => {
setInside(false);
setSnap(null);
};
el.addEventListener("pointermove", onMove);
el.addEventListener("pointerenter", onEnter);
el.addEventListener("pointerleave", onLeave);
return () => {
el.removeEventListener("pointermove", onMove);
el.removeEventListener("pointerenter", onEnter);
el.removeEventListener("pointerleave", onLeave);
};
}, [fine, ringSize, snapPadding, rx, ry, rw, rh, rr, dotX, dotY]);
return (
<div
ref={hostRef}
className={cn(
"relative overflow-hidden",
fine && "[&_*]:cursor-none",
className,
)}
style={fine ? { cursor: "none" } : undefined}
{...props}
>
{children}
{fine && (
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-50"
style={{ opacity: inside ? 1 : 0, transition: "opacity 160ms ease" }}
>
<motion.div
className="absolute top-0 left-0"
style={{
x: reduced ? rx : ringX,
y: reduced ? ry : ringY,
width: reduced ? rw : ringW,
height: reduced ? rh : ringH,
borderRadius: reduced ? rr : ringR,
translateX: "-50%",
translateY: "-50%",
border: `${ringWidth}px solid ${color}`,
opacity: snap ? 0.9 : 0.6,
boxShadow: snap
? `0 0 0 1px color-mix(in oklch, ${color} 12%, transparent)`
: "none",
}}
/>
<motion.span
className="absolute top-0 left-0 rounded-full"
style={{
x: reduced ? dotX : dotSpringX,
y: reduced ? dotY : dotSpringY,
width: 4,
height: 4,
marginLeft: -2,
marginTop: -2,
background: color,
opacity: snap ? 0 : 1,
transition: "opacity 120ms ease",
}}
/>
</div>
)}
</div>
);
}