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 {
animate,
motion,
useMotionValue,
useReducedMotion,
useSpring,
useTransform,
type MotionValue,
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. While hugging, the ring is gently pulled toward the
* pointer (a real magnet has give); pressing contracts it springily. A center
* dot stays locked to the true pointer over open space so precision is never
* lost, and on entry the ring teleports to the entry point — no fly-in streak.
*
* The ring is an SVG rect whose geometry attributes ride springs, so nothing
* animates CSS layout. 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 1:1 ring and drops all spring 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 };
const PRESS_SPRING: SpringOptions = { stiffness: 500, damping: 30, mass: 0.5 };
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 insideRef = React.useRef(false);
const [snapped, setSnapped] = React.useState(false);
const reduced = useReducedMotion();
// Ring geometry targets (center + size + radius) with sprung mirrors.
const rx = useMotionValue(-9999);
const ry = useMotionValue(-9999);
const rw = useMotionValue(ringSize);
const rh = useMotionValue(ringSize);
const rr = useMotionValue(ringSize / 2);
const cx = useSpring(rx, RING_SPRING);
const cy = useSpring(ry, RING_SPRING);
const cw = useSpring(rw, RING_SPRING);
const ch = useSpring(rh, RING_SPRING);
const cr = useSpring(rr, RING_SPRING);
const press = useMotionValue(1);
// The rect's attributes derive from sprung center/size × press, so the press
// contraction stays centered and never touches CSS layout.
const rectW = useTransform([cw, press], ([w, p]: number[]) =>
Math.max(0, w * p),
);
const rectH = useTransform([ch, press], ([h, p]: number[]) =>
Math.max(0, h * p),
);
const rectX = useTransform([cx, rectW], ([c, w]: number[]) => c - w / 2);
const rectY = useTransform([cy, rectH], ([c, h]: number[]) => c - h / 2);
const rectR = useTransform([cr, press], ([r, p]: number[]) =>
Math.max(0, r * p),
);
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 ringVals: Array<[MotionValue<number>, MotionValue<number>]> = [
[rx, cx],
[ry, cy],
[rw, cw],
[rh, ch],
[rr, cr],
];
const setRing = (vals: number[], hard: boolean) => {
ringVals.forEach(([raw, sprung], i) => {
if (hard) {
raw.jump(vals[i]);
sprung.jump(vals[i]);
} else {
raw.set(vals[i]);
}
});
};
// Snapped-target geometry is measured once per target change, never per
// move — pointermove only writes motion values.
let snapTarget: HTMLElement | null = null;
let snapRect: { cx: number; cy: number; w: number; h: number; r: number } | null =
null;
const onMove = (e: PointerEvent) => {
const rect = el.getBoundingClientRect();
const px = e.clientX - rect.left;
const py = e.clientY - rect.top;
const firstContact = !insideRef.current;
if (firstContact) {
insideRef.current = true;
setInside(true);
}
if (reduced) {
dotSpringX.jump(px);
dotSpringY.jump(py);
} else if (firstContact) {
dotX.jump(px);
dotY.jump(py);
dotSpringX.jump(px);
dotSpringY.jump(py);
} else {
dotX.set(px);
dotY.set(py);
}
const target = (e.target as Element | null)?.closest?.(
"[data-magnetic]",
) as HTMLElement | null;
const valid = target && el.contains(target) ? target : null;
if (valid !== snapTarget) {
snapTarget = valid;
if (valid) {
const tRect = valid.getBoundingClientRect();
const cs = window.getComputedStyle(valid);
snapRect = {
cx: tRect.left - rect.left + tRect.width / 2,
cy: tRect.top - rect.top + tRect.height / 2,
w: tRect.width + snapPadding * 2,
h: tRect.height + snapPadding * 2,
r: (parseFloat(cs.borderTopLeftRadius) || 0) + snapPadding,
};
} else {
snapRect = null;
}
setSnapped(!!valid);
}
if (snapRect) {
// Magnetic give: the hugging ring leans 10% toward the pointer.
const pullX = snapRect.cx + (px - snapRect.cx) * 0.1;
const pullY = snapRect.cy + (py - snapRect.cy) * 0.1;
setRing(
[pullX, pullY, snapRect.w, snapRect.h, snapRect.r],
reduced || firstContact,
);
} else {
setRing(
[px, py, ringSize, ringSize, ringSize / 2],
reduced || firstContact,
);
}
};
const onEnter = (e: PointerEvent) => onMove(e);
const onLeave = () => {
insideRef.current = false;
setInside(false);
snapTarget = null;
snapRect = null;
setSnapped(false);
animate(press, 1, PRESS_SPRING);
};
const onDown = () => animate(press, 0.92, PRESS_SPRING);
const onUp = () => animate(press, 1, PRESS_SPRING);
el.addEventListener("pointermove", onMove);
el.addEventListener("pointerenter", onEnter);
el.addEventListener("pointerleave", onLeave);
el.addEventListener("pointerdown", onDown);
el.addEventListener("pointerup", onUp);
return () => {
el.removeEventListener("pointermove", onMove);
el.removeEventListener("pointerenter", onEnter);
el.removeEventListener("pointerleave", onLeave);
el.removeEventListener("pointerdown", onDown);
el.removeEventListener("pointerup", onUp);
};
}, [
fine,
reduced,
ringSize,
snapPadding,
press,
rx,
ry,
rw,
rh,
rr,
cx,
cy,
cw,
ch,
cr,
dotX,
dotY,
dotSpringX,
dotSpringY,
]);
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" }}
>
<svg className="absolute inset-0 size-full overflow-visible">
{/* Soft halo, visible only while hugging a target. */}
<motion.rect
x={rectX}
y={rectY}
width={rectW}
height={rectH}
rx={rectR}
fill="none"
stroke={color}
strokeWidth={ringWidth + 3}
style={{
opacity: snapped ? 0.16 : 0,
transition: "opacity 150ms var(--ease-out)",
}}
/>
<motion.rect
x={rectX}
y={rectY}
width={rectW}
height={rectH}
rx={rectR}
fill="none"
stroke={color}
strokeWidth={ringWidth}
style={{
opacity: snapped ? 0.95 : 0.6,
transition: "opacity 150ms var(--ease-out)",
}}
/>
</svg>
<motion.span
className="absolute top-0 left-0 rounded-full"
style={{
x: dotSpringX,
y: dotSpringY,
width: 4,
height: 4,
marginLeft: -2,
marginTop: -2,
background: color,
opacity: snapped ? 0 : 1,
transition: "opacity 120ms ease",
}}
/>
</div>
)}
</div>
);
}