Interactive
MagneticCursor
A ring replaces the mouse cursor inside an area. Over a link or button it snaps on and takes that element's shape, leaning a little toward the pointer. Touch and reduced motion keep the normal behaviour.
Download
- React
- Tailwind CSS v4
- motion
components/ui/magnetic-cursor.tsx
"use client";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { motion, useReducedMotion, useSpring, useTransform } from "motion/react";
type MagneticCursorProps = {
/** The area that gets the custom cursor. */
children: ReactNode;
/** What the ring wraps around. */
selector?: string;
/** Ring size in pixels when it is not on a target. */
size?: number;
/** Pixels between the ring and the target it wraps. */
padding?: number;
/** How far the ring leans toward the pointer on a target, 0 to 1. */
pull?: number;
/** Ring colour. */
accent?: string;
className?: string;
};
const SPRING = { stiffness: 420, damping: 34, mass: 0.6 };
/* MagneticCursor: a ring replaces the mouse cursor. Over a link or button it snaps on and takes the shape of that element. */
export function MagneticCursor({
children,
selector = "a, button, [data-magnetic]",
size = 28,
padding = 8,
pull = 0.2,
accent = "#a78bfa",
className = "",
}: MagneticCursorProps) {
const reduce = useReducedMotion();
const zone = useRef<HTMLDivElement>(null);
const last = useRef<{ x: number; y: number } | null>(null);
const [shown, setShown] = useState(false);
const x = useSpring(0, SPRING);
const y = useSpring(0, SPRING);
const width = useSpring(size, SPRING);
const height = useSpring(size, SPRING);
const radius = useSpring(size / 2, SPRING);
const left = useTransform([x, width], ([a, b]: number[]) => a - b / 2);
const top = useTransform([y, height], ([a, b]: number[]) => a - b / 2);
const place = useCallback(
(cx: number, cy: number) => {
const box = zone.current;
if (!box) return;
const r = box.getBoundingClientRect();
const hit = document.elementFromPoint(cx, cy)?.closest(selector);
if (hit && box.contains(hit)) {
const t = hit.getBoundingClientRect();
const mx = t.left + t.width / 2;
const my = t.top + t.height / 2;
x.set(mx + (cx - mx) * pull - r.left);
y.set(my + (cy - my) * pull - r.top);
width.set(t.width + padding * 2);
height.set(t.height + padding * 2);
// rounded-full computes to a huge number, so cap it at a pill.
radius.set(Math.min(parseFloat(getComputedStyle(hit).borderTopLeftRadius) + padding, t.height / 2 + padding));
} else {
x.set(cx - r.left);
y.set(cy - r.top);
width.set(size);
height.set(size);
radius.set(size / 2);
}
},
[selector, pull, padding, size, x, y, width, height, radius],
);
// A wheel scroll moves the page under a still mouse.
useEffect(() => {
if (!shown) return;
const onScroll = () => last.current && place(last.current.x, last.current.y);
window.addEventListener("scroll", onScroll, { capture: true, passive: true });
return () => window.removeEventListener("scroll", onScroll, { capture: true });
}, [shown, place]);
const onMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (reduce || e.pointerType !== "mouse") return;
if (!last.current) {
const r = e.currentTarget.getBoundingClientRect();
x.jump(e.clientX - r.left);
y.jump(e.clientY - r.top);
setShown(true);
}
last.current = { x: e.clientX, y: e.clientY };
place(e.clientX, e.clientY);
};
const onLeave = () => {
last.current = null;
setShown(false);
};
return (
<div
ref={zone}
onPointerMove={onMove}
onPointerLeave={onLeave}
className={`relative ${shown ? "[&,&_*]:cursor-none" : ""} ${className}`}
>
{children}
<motion.div
aria-hidden
className="pointer-events-none absolute left-0 top-0 z-50 border-2"
animate={{ opacity: shown ? 1 : 0 }}
transition={{ duration: 0.15 }}
style={{ x: left, y: top, width, height, borderRadius: radius, borderColor: accent, backgroundColor: `${accent}1a` }}
/>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/magnetic-cursor.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/magnetic-cursor-demo.tsx
import { MagneticCursor } from "@/components/ui/magnetic-cursor"; export default function MagneticCursorExample() { return ( <MagneticCursor className="grid h-full place-items-center overflow-hidden bg-[#0b0a14] p-6"> <div className="grid justify-items-center gap-6 text-center"> <p className="text-sm text-white/55">Move the mouse over the buttons.</p> <div className="flex flex-wrap justify-center gap-5"> <button type="button" className="rounded-full bg-white px-5 py-2.5 text-sm font-medium text-[#0b0a14]"> Get started </button> <button type="button" className="rounded-lg border border-white/15 px-5 py-2.5 text-sm text-white"> Read the docs </button> </div> <a href="#" className="text-sm text-[#a78bfa] underline underline-offset-4"> See the changelog </a> </div> </MagneticCursor> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| children | ReactNode | — | The area that gets the custom cursor. |
| selector | string | "a, button, [data-magnetic]" | What the ring wraps around. |
| size | number | 28 | Ring size in pixels when it is not on a target. |
| padding | number | 8 | Pixels between the ring and the target it wraps. |
| pull | number | 0.2 | How far the ring leans toward the pointer on a target, 0 to 1. |
| accent | string | "#a78bfa" | Ring colour. |
| className | string | "" | On the area wrapper. |