Magnetic Cursor
Cursors & Pointer

Magnetic Cursor

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.

Install

npx shadcn@latest add @paragon/magnetic-cursor

magnetic-cursor.tsx

"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>
  );
}