Web InnoventixFreeCode

Magnetic Cursor

Original · free

magnetic cursor on targets

byWeb InnoventixReact + Tailwind
curmagneticcursors
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/cur-magnetic.json
cur-magnetic.tsx
"use client";

import {
  useEffect,
  useRef,
  useState,
  type ChangeEvent,
  type CSSProperties,
  type ReactNode,
  type PointerEvent as ReactPointerEvent,
} from "react";
import { motion, useMotionValue, useSpring, useReducedMotion } from "motion/react";

type Snap = { cx: number; cy: number; w: number; h: number; r: number } | null;
type AccentKey = "indigo" | "violet" | "emerald" | "rose";
type ReactKey = "spark" | "star" | "save";

const ACCENTS: Record<AccentKey, { label: string; hex: string; soft: string }> = {
  indigo: { label: "Indigo", hex: "#6366f1", soft: "rgba(99,102,241,0.14)" },
  violet: { label: "Violet", hex: "#8b5cf6", soft: "rgba(139,92,246,0.14)" },
  emerald: { label: "Emerald", hex: "#10b981", soft: "rgba(16,185,129,0.14)" },
  rose: { label: "Rose", hex: "#f43f5e", soft: "rgba(244,63,94,0.14)" },
};

const REACTIONS: { key: ReactKey; label: string; base: number }[] = [
  { key: "spark", label: "Spark", base: 128 },
  { key: "star", label: "Star", base: 64 },
  { key: "save", label: "Save", base: 39 },
];

const FOCUS_RING =
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 " +
  "focus-visible:ring-neutral-900 dark:focus-visible:ring-white " +
  "focus-visible:ring-offset-white dark:focus-visible:ring-offset-neutral-950";

function Icon({
  name,
  className,
  style,
}: {
  name: ReactKey | "arrow" | "pointer";
  className?: string;
  style?: CSSProperties;
}) {
  const common = {
    viewBox: "0 0 24 24",
    className,
    style,
    "aria-hidden": true as const,
  };
  if (name === "spark") {
    return (
      <svg {...common} fill="currentColor">
        <path d="M13 2 4 14h6l-1 8 9-12h-6l1-8Z" />
      </svg>
    );
  }
  if (name === "star") {
    return (
      <svg {...common} fill="currentColor">
        <path d="M12 3.2l2.6 5.5 6 .7-4.4 4.1 1.1 5.9-5.3-2.9-5.3 2.9 1.1-5.9L3 9.4l6-.7z" />
      </svg>
    );
  }
  if (name === "save") {
    return (
      <svg {...common} fill="currentColor">
        <path d="M6 3h12a1 1 0 0 1 1 1v17l-7-4.4L5 21V4a1 1 0 0 1 1-1z" />
      </svg>
    );
  }
  if (name === "pointer") {
    return (
      <svg {...common} fill="currentColor">
        <path d="M6 3l14 7-6 1.6L11 20 6 3z" />
      </svg>
    );
  }
  return (
    <svg {...common} fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
      <path d="M5 12h14M13 6l6 6-6 6" />
    </svg>
  );
}

function MagneticTarget({
  children,
  kind,
  magnetic,
  snap,
  strength,
  onSnap,
  onRelease,
  onClick,
  href,
  ariaLabel,
  ariaPressed,
  className,
  style,
}: {
  children: ReactNode;
  kind: "button" | "link";
  magnetic: boolean;
  snap: boolean;
  strength: number;
  onSnap: (s: Snap) => void;
  onRelease: () => void;
  onClick?: () => void;
  href?: string;
  ariaLabel?: string;
  ariaPressed?: boolean;
  className?: string;
  style?: CSSProperties;
}) {
  const ref = useRef<HTMLElement | null>(null);
  const x = useSpring(0, { stiffness: 220, damping: 15, mass: 0.35 });
  const y = useSpring(0, { stiffness: 220, damping: 15, mass: 0.35 });

  const rect = () => ref.current?.getBoundingClientRect() ?? null;

  const report = () => {
    if (!snap) return;
    const r = rect();
    if (!r) return;
    onSnap({ cx: r.left + r.width / 2, cy: r.top + r.height / 2, w: r.width, h: r.height, r: 16 });
  };

  const move = (e: ReactPointerEvent) => {
    if (!magnetic) return;
    const r = rect();
    if (!r) return;
    x.set((e.clientX - (r.left + r.width / 2)) * strength);
    y.set((e.clientY - (r.top + r.height / 2)) * strength);
  };

  const settle = () => {
    x.set(0);
    y.set(0);
    onRelease();
  };

  const handlers = {
    onPointerMove: move,
    onPointerEnter: report,
    onPointerLeave: settle,
    onFocus: report,
    onBlur: settle,
  };

  if (kind === "link") {
    return (
      <motion.a
        ref={(el) => {
          ref.current = el;
        }}
        href={href}
        onClick={(e) => {
          e.preventDefault();
          onClick?.();
        }}
        aria-label={ariaLabel}
        style={{ x, y, ...style }}
        className={className}
        {...handlers}
      >
        {children}
      </motion.a>
    );
  }

  return (
    <motion.button
      ref={(el) => {
        ref.current = el;
      }}
      type="button"
      onClick={onClick}
      aria-label={ariaLabel}
      aria-pressed={ariaPressed}
      style={{ x, y, ...style }}
      className={className}
      {...handlers}
    >
      {children}
    </motion.button>
  );
}

function Switch({
  checked,
  onChange,
  label,
  accentHex,
  disabled,
}: {
  checked: boolean;
  onChange: (v: boolean) => void;
  label: string;
  accentHex: string;
  disabled: boolean;
}) {
  return (
    <button
      type="button"
      role="switch"
      aria-checked={checked}
      disabled={disabled}
      onClick={() => onChange(!checked)}
      className={
        "group inline-flex items-center gap-3 rounded-full text-sm font-medium text-neutral-700 dark:text-neutral-200 " +
        (disabled ? "cursor-not-allowed opacity-45 " : "cursor-pointer ") +
        FOCUS_RING
      }
    >
      <span
        className="relative h-6 w-11 shrink-0 rounded-full bg-neutral-300 transition-colors dark:bg-neutral-700"
        style={checked && !disabled ? { backgroundColor: accentHex } : undefined}
      >
        <span
          className={
            "absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow-sm transition-transform duration-200 " +
            (checked ? "translate-x-5" : "translate-x-0")
          }
        />
      </span>
      {label}
    </button>
  );
}

export default function MagneticCursor() {
  const reduced = !!useReducedMotion();

  const [finePointer, setFinePointer] = useState(false);
  useEffect(() => {
    const mq = window.matchMedia("(pointer: fine)");
    const update = () => setFinePointer(mq.matches);
    update();
    mq.addEventListener("change", update);
    return () => mq.removeEventListener("change", update);
  }, []);

  const [accent, setAccent] = useState<AccentKey>("indigo");
  const [enabled, setEnabled] = useState(true);
  const [customCursor, setCustomCursor] = useState(true);
  const [strengthPct, setStrengthPct] = useState(35);
  const [reacts, setReacts] = useState<Record<ReactKey, boolean>>({
    spark: false,
    star: false,
    save: false,
  });
  const [sent, setSent] = useState(false);
  const [status, setStatus] = useState("Bring the pointer near a target to feel the pull.");

  const [inside, setInside] = useState(false);
  const [snapped, setSnapped] = useState(false);

  const accentHex = ACCENTS[accent].hex;
  const accentSoft = ACCENTS[accent].soft;
  const strength = strengthPct / 100;

  const enhance = finePointer && !reduced;
  const magnetic = enhance && enabled;
  const cursorOn = enhance && customCursor;

  const dotX = useMotionValue(-100);
  const dotY = useMotionValue(-100);
  const ringX = useSpring(-100, { stiffness: 350, damping: 30, mass: 0.6 });
  const ringY = useSpring(-100, { stiffness: 350, damping: 30, mass: 0.6 });
  const ringW = useSpring(36, { stiffness: 260, damping: 26 });
  const ringH = useSpring(36, { stiffness: 260, damping: 26 });
  const ringR = useSpring(18, { stiffness: 260, damping: 26 });

  const snapRef = useRef<Snap>(null);

  const handleSnap = (s: Snap) => {
    snapRef.current = s;
    if (!s) return;
    ringX.set(s.cx);
    ringY.set(s.cy);
    ringW.set(s.w + 16);
    ringH.set(s.h + 16);
    ringR.set(s.r + 8);
    setSnapped(true);
  };

  const handleRelease = () => {
    snapRef.current = null;
    ringW.set(36);
    ringH.set(36);
    ringR.set(18);
    setSnapped(false);
  };

  const canvasMove = (e: ReactPointerEvent) => {
    if (!cursorOn) return;
    dotX.set(e.clientX);
    dotY.set(e.clientY);
    if (!snapRef.current) {
      ringX.set(e.clientX);
      ringY.set(e.clientY);
    }
  };

  const canvasEnter = () => {
    if (cursorOn) setInside(true);
  };

  const canvasLeave = () => {
    setInside(false);
    handleRelease();
  };

  const toggleReact = (k: ReactKey, label: string) => {
    const willBe = !reacts[k];
    setReacts((prev) => ({ ...prev, [k]: willBe }));
    setStatus(willBe ? `Added your ${label.toLowerCase()}.` : `Removed your ${label.toLowerCase()}.`);
  };

  const handleCTA = () => {
    setSent(true);
    setStatus("Request received — a strategist will reply within one business day.");
  };

  const targetBase =
    "relative inline-flex select-none items-center gap-2 rounded-full border px-4 py-2.5 text-sm font-medium " +
    "bg-white/70 backdrop-blur transition-colors dark:bg-neutral-900/60 " +
    FOCUS_RING;

  const cursorVisible = cursorOn && (inside || snapped);
  const styleVar: CSSProperties = { WebkitTapHighlightColor: "transparent" };

  return (
    <section
      className="relative w-full overflow-hidden bg-gradient-to-b from-white to-neutral-100 px-6 py-20 dark:from-neutral-950 dark:to-neutral-900 sm:py-24"
      style={styleVar}
    >
      <style>{`
        @keyframes curmag-float {
          0%, 100% { transform: translate3d(0,0,0) scale(1); }
          50% { transform: translate3d(0,-18px,0) scale(1.06); }
        }
        @keyframes curmag-drift {
          0%, 100% { transform: translate3d(0,0,0) scale(1); }
          50% { transform: translate3d(18px,12px,0) scale(1.05); }
        }
        @keyframes curmag-hint {
          0%, 100% { opacity: 0.45; transform: scale(1); }
          50% { opacity: 1; transform: scale(1.35); }
        }
        @media (prefers-reduced-motion: reduce) {
          .curmag-anim { animation: none !important; }
        }
      `}</style>

      {/* custom cursor overlay */}
      {cursorOn && (
        <div
          aria-hidden="true"
          className="pointer-events-none fixed inset-0 z-[70] transition-opacity duration-200"
          style={{ opacity: cursorVisible ? 1 : 0 }}
        >
          <motion.div className="absolute left-0 top-0 will-change-transform" style={{ x: ringX, y: ringY }}>
            <motion.div
              className="box-border -translate-x-1/2 -translate-y-1/2 border-2"
              style={{
                width: ringW,
                height: ringH,
                borderRadius: ringR,
                borderColor: accentHex,
                backgroundColor: snapped ? accentSoft : "transparent",
              }}
            />
          </motion.div>
          <motion.div className="absolute left-0 top-0 will-change-transform" style={{ x: dotX, y: dotY }}>
            <span
              className="block h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full"
              style={{ backgroundColor: accentHex }}
            />
          </motion.div>
        </div>
      )}

      <div className="mx-auto w-full max-w-5xl">
        <header className="max-w-2xl">
          <span className="inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white/60 px-3 py-1 text-xs font-medium uppercase tracking-wider text-neutral-500 dark:border-neutral-800 dark:bg-neutral-900/60 dark:text-neutral-400">
            <Icon name="pointer" className="h-3.5 w-3.5" style={{ color: accentHex }} />
            Cursor interaction
          </span>
          <h2 className="mt-5 text-balance text-3xl font-bold tracking-tight text-neutral-900 dark:text-white sm:text-4xl">
            The magnetic cursor
          </h2>
          <p className="mt-4 text-pretty text-base leading-relaxed text-neutral-600 dark:text-neutral-400">
            The pointer is not just a pointer. Bring it near a target and it gets pulled in, wraps the control,
            and snaps to focus, so every button feels like it wants to be pressed.
          </p>
        </header>

        <div
          onPointerMove={canvasMove}
          onPointerEnter={canvasEnter}
          onPointerLeave={canvasLeave}
          className={
            "relative mt-10 overflow-hidden rounded-3xl border border-neutral-200 bg-white/50 p-6 backdrop-blur-sm dark:border-neutral-800 dark:bg-neutral-950/40 sm:p-9 " +
            (cursorOn ? "cursor-none" : "")
          }
        >
          {/* ambient wash */}
          <div aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden">
            <div
              className="curmag-anim absolute -left-24 -top-16 h-72 w-72 rounded-full blur-3xl"
              style={{
                background: `radial-gradient(circle, ${accentSoft}, transparent 70%)`,
                animation: "curmag-float 9s ease-in-out infinite",
              }}
            />
            <div
              className="curmag-anim absolute -bottom-24 -right-20 h-80 w-80 rounded-full blur-3xl"
              style={{
                background: "radial-gradient(circle, rgba(244,63,94,0.14), transparent 70%)",
                animation: "curmag-drift 11s ease-in-out infinite",
              }}
            />
          </div>

          {/* keyboard hint */}
          <div className="pointer-events-none absolute right-4 top-4 z-10 hidden items-center gap-2 rounded-full border border-neutral-200/70 bg-white/70 px-3 py-1.5 text-xs text-neutral-500 backdrop-blur dark:border-neutral-700/70 dark:bg-neutral-900/70 dark:text-neutral-400 sm:inline-flex">
            <span
              className="curmag-anim inline-block h-1.5 w-1.5 rounded-full"
              style={{ backgroundColor: accentHex, animation: "curmag-hint 1.8s ease-in-out infinite" }}
            />
            Tab to snap focus
          </div>

          <div className="relative">
            {/* controls */}
            <div
              role="group"
              aria-label="Cursor controls"
              className="flex flex-col gap-6 rounded-2xl border border-neutral-200/80 bg-white/70 p-5 dark:border-neutral-800/80 dark:bg-neutral-900/50 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between"
            >
              <div className="flex flex-wrap items-center gap-x-6 gap-y-4">
                <Switch
                  checked={enabled}
                  onChange={setEnabled}
                  label="Magnetism"
                  accentHex={accentHex}
                  disabled={!enhance}
                />
                <Switch
                  checked={customCursor}
                  onChange={setCustomCursor}
                  label="Custom cursor"
                  accentHex={accentHex}
                  disabled={!enhance}
                />
              </div>

              <label className="flex min-w-[220px] flex-1 items-center gap-3 text-sm font-medium text-neutral-700 dark:text-neutral-200">
                <span className="whitespace-nowrap">Pull strength</span>
                <input
                  type="range"
                  min={15}
                  max={55}
                  step={5}
                  value={strengthPct}
                  disabled={!enhance}
                  onChange={(e: ChangeEvent<HTMLInputElement>) => setStrengthPct(Number(e.target.value))}
                  aria-label="Pull strength"
                  aria-valuetext={(strengthPct / 100).toFixed(2)}
                  className={
                    "h-1.5 flex-1 cursor-pointer appearance-none rounded-full bg-neutral-200 dark:bg-neutral-700 disabled:opacity-45 " +
                    FOCUS_RING
                  }
                  style={{ accentColor: accentHex }}
                />
                <span className="w-9 text-right tabular-nums text-neutral-500 dark:text-neutral-400">
                  {(strength).toFixed(2)}
                </span>
              </label>

              <fieldset className="flex items-center gap-3">
                <legend className="sr-only">Cursor accent colour</legend>
                <span className="text-sm font-medium text-neutral-700 dark:text-neutral-200">Accent</span>
                <div className="flex items-center gap-2">
                  {(Object.keys(ACCENTS) as AccentKey[]).map((key) => (
                    <label key={key} className="relative inline-flex cursor-pointer">
                      <input
                        type="radio"
                        name="curmag-accent"
                        value={key}
                        checked={accent === key}
                        onChange={() => setAccent(key)}
                        aria-label={ACCENTS[key].label}
                        className="peer sr-only"
                      />
                      <span
                        aria-hidden="true"
                        className="block h-7 w-7 rounded-full ring-2 ring-transparent ring-offset-2 ring-offset-white transition dark:ring-offset-neutral-900 peer-checked:ring-neutral-900 dark:peer-checked:ring-white peer-focus-visible:ring-neutral-900 dark:peer-focus-visible:ring-white"
                        style={{ backgroundColor: ACCENTS[key].hex }}
                      />
                    </label>
                  ))}
                </div>
              </fieldset>
            </div>

            {/* targets */}
            <div className="mt-8">
              <p className="text-xs font-semibold uppercase tracking-wider text-neutral-400 dark:text-neutral-500">
                Magnetic targets
              </p>

              <div className="mt-4 flex flex-wrap items-center gap-3">
                {REACTIONS.map(({ key, label, base }) => {
                  const on = reacts[key];
                  const count = base + (on ? 1 : 0);
                  return (
                    <MagneticTarget
                      key={key}
                      kind="button"
                      magnetic={magnetic}
                      snap={cursorOn}
                      strength={strength}
                      onSnap={handleSnap}
                      onRelease={handleRelease}
                      onClick={() => toggleReact(key, label)}
                      ariaPressed={on}
                      ariaLabel={`${label}, ${count} reactions${on ? ", selected" : ""}`}
                      className={
                        targetBase +
                        (on
                          ? ""
                          : " border-neutral-200 text-neutral-700 dark:border-neutral-700 dark:text-neutral-200")
                      }
                      style={on ? { color: accentHex, borderColor: accentHex, backgroundColor: accentSoft } : undefined}
                    >
                      <Icon name={key} className="h-4 w-4" />
                      <span>{label}</span>
                      <span className="tabular-nums text-xs text-neutral-400 dark:text-neutral-500">{count}</span>
                    </MagneticTarget>
                  );
                })}
              </div>

              <div className="mt-6 flex flex-wrap items-center gap-x-6 gap-y-4">
                <MagneticTarget
                  kind="button"
                  magnetic={magnetic}
                  snap={cursorOn}
                  strength={strength}
                  onSnap={handleSnap}
                  onRelease={handleRelease}
                  onClick={handleCTA}
                  ariaLabel="Start a project"
                  className={
                    "inline-flex items-center gap-2 rounded-2xl px-6 py-4 text-base font-semibold text-white shadow-lg shadow-black/10 transition-transform " +
                    FOCUS_RING
                  }
                  style={{ backgroundColor: accentHex }}
                >
                  <span>{sent ? "Request received" : "Start a project"}</span>
                  <Icon name="arrow" className="h-5 w-5" />
                </MagneticTarget>

                <MagneticTarget
                  kind="link"
                  href="#magnetic-pattern"
                  magnetic={magnetic}
                  snap={cursorOn}
                  strength={strength}
                  onSnap={handleSnap}
                  onRelease={handleRelease}
                  onClick={() => setStatus("Opening the pattern notes.")}
                  ariaLabel="Read the pattern notes"
                  className={
                    "inline-flex items-center gap-1.5 rounded-md text-sm font-medium text-neutral-600 underline-offset-4 hover:underline dark:text-neutral-300 " +
                    FOCUS_RING
                  }
                >
                  Read the pattern notes
                  <Icon name="arrow" className="h-4 w-4" />
                </MagneticTarget>
              </div>
            </div>

            {/* status + note */}
            <div className="mt-8 flex flex-col gap-2 border-t border-neutral-200/70 pt-5 dark:border-neutral-800/70">
              <p aria-live="polite" className="text-sm text-neutral-500 dark:text-neutral-400">
                {status}
              </p>
              {!enhance && (
                <p className="text-xs text-neutral-400 dark:text-neutral-500">
                  {finePointer
                    ? "Reduced-motion is on, so the cursor and pull are disabled. Every control still works."
                    : "You are on a touch device, so the magnetic cursor is off. Every control still works by tap and keyboard."}
                </p>
              )}
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →