Magnetic Cursor
Original · freemagnetic cursor on targets
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"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
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 quoteSimilar components
Browse all →
Dot Follow Cursor
Originaldot cursor follower

Ring Cursor
Originalring cursor follower

Trail Cursor
Originalcursor trail effect

Spotlight Cursor
Originalcursor spotlight mask

Blend Cursor
Originalmix-blend cursor

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero
OriginalA minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.

App Preview Hero
OriginalA centred hero that pairs headline copy with a realistic product dashboard mock built entirely from markup, complete with browser chrome and a floating notification card.

Waitlist Capture Hero
OriginalA dark, focused hero with an inline email capture form and avatar social proof, ready for pre-launch waitlists.

Image Backdrop Hero
OriginalA cinematic full-bleed hero with a layered image-style backdrop, overlaid left-aligned copy and a trusted-by logos strip.

Gradient Mesh Hero with Staggered Text Reveal
OriginalA full-screen hero pairing a drifting animated gradient-mesh backdrop with a word-by-word staggered blur-in headline and a logo marquee under the fold.

