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