A large white pill button on a cobalt hero that leans toward the pointer once it comes within a set radius, then springs home with a small overshoot when the pointer leaves. The label moves a little further than the button for depth, the shadow shifts the opposite way and a dashed ring shows the edge of the field. While nobody is interacting, a demo pointer traces a figure of eight so the pull is visible. On phones the copy stacks above the button and a finger drag works the same as a mouse.
import { useEffect, useRef, useState } from "react";
import { motion, useMotionValue, useReducedMotion, useSpring, useTransform } from "framer-motion";
// How far away the button starts to feel the pointer, and how much of the distance it travels.
const RADIUS = 150;
const STRENGTH = 0.42;
const SPRING = { stiffness: 190, damping: 11, mass: 0.7 };
export default function MagneticButtonThatFollowsThePointer() {
const reduceMotion = useReducedMotion();
const zoneRef = useRef<HTMLDivElement>(null);
const [demo, setDemo] = useState(true);
const [booked, setBooked] = useState(false);
// Where the button wants to be, relative to its resting place.
const targetX = useMotionValue(0);
const targetY = useMotionValue(0);
const x = useSpring(targetX, SPRING);
const y = useSpring(targetY, SPRING);
// The label travels a little further than the button, which reads as depth.
const labelX = useTransform(x, (v) => v * 0.35);
const labelY = useTransform(y, (v) => v * 0.35);
const shadowX = useTransform(x, (v) => v * -0.3);
const shadowY = useTransform(y, (v) => 18 + v * -0.3);
// 0 when nothing is near, 1 when the pointer sits on the button.
const grip = useMotionValue(0);
const gripSoft = useSpring(grip, { stiffness: 120, damping: 20 });
const ringScale = useTransform(gripSoft, [0, 1], [1, 0.86]);
const ringOpacity = useTransform(gripSoft, [0, 1], [0.35, 0.95]);
// The demo pointer that plays while nobody is touching the component.
const ghostX = useMotionValue(0);
const ghostY = useMotionValue(0);
const pull = (dx: number, dy: number) => {
const distance = Math.hypot(dx, dy);
if (distance < RADIUS) {
targetX.set(dx * STRENGTH);
targetY.set(dy * STRENGTH);
grip.set(1 - distance / RADIUS);
} else {
targetX.set(0);
targetY.set(0);
grip.set(0);
}
};
// Real pointer (mouse, pen or finger) anywhere inside the zone.
useEffect(() => {
const zone = zoneRef.current;
if (!zone || reduceMotion) return;
let resume: ReturnType<typeof setTimeout> | undefined;
const onMove = (event: PointerEvent) => {
clearTimeout(resume);
setDemo(false);
const rect = zone.getBoundingClientRect();
pull(event.clientX - (rect.left + rect.width / 2), event.clientY - (rect.top + rect.height / 2));
};
const onLeave = () => {
pull(RADIUS * 2, 0);
clearTimeout(resume);
resume = setTimeout(() => setDemo(true), 2600);
};
zone.addEventListener("pointermove", onMove);
zone.addEventListener("pointerdown", onMove);
zone.addEventListener("pointerleave", onLeave);
zone.addEventListener("pointercancel", onLeave);
return () => {
clearTimeout(resume);
zone.removeEventListener("pointermove", onMove);
zone.removeEventListener("pointerdown", onMove);
zone.removeEventListener("pointerleave", onLeave);
zone.removeEventListener("pointercancel", onLeave);
};
}, [reduceMotion]);
// Demo pointer: traces a figure of eight through the field so the pull shows without input.
useEffect(() => {
const zone = zoneRef.current;
if (!zone || reduceMotion || !demo) return;
let frame = 0;
let visible = true;
let elapsed = 0;
let last = 0;
const tick = (now: number) => {
frame = 0;
if (last) elapsed += Math.min(now - last, 50) / 1000;
last = now;
const reach = Math.min(170, zone.clientWidth / 2 - 70);
const angle = elapsed * 1.15 + 0.5;
const gx = Math.sin(angle) * reach;
const gy = Math.sin(angle * 2) * 62;
ghostX.set(gx);
ghostY.set(gy);
pull(gx, gy);
schedule();
};
const schedule = () => {
if (!frame && visible && !document.hidden) frame = requestAnimationFrame(tick);
};
const stop = () => {
if (frame) cancelAnimationFrame(frame);
frame = 0;
last = 0;
};
const observer = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
if (visible) schedule();
else stop();
});
observer.observe(zone);
const onVisibility = () => (document.hidden ? stop() : schedule());
document.addEventListener("visibilitychange", onVisibility);
schedule();
return () => {
stop();
observer.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
};
}, [demo, reduceMotion]);
return (
<section className="relative overflow-hidden bg-blue-600 px-5 py-14 text-white sm:px-8 sm:py-16">
{/* Dot grid and a soft highlight so the blue has depth */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 opacity-40"
style={{ backgroundImage: "radial-gradient(rgba(255,255,255,0.55) 1px, transparent 1.5px)", backgroundSize: "22px 22px" }}
/>
<div aria-hidden="true" className="pointer-events-none absolute inset-0 bg-gradient-to-b from-blue-600/0 via-blue-600/60 to-blue-700" />
<div className="relative mx-auto grid max-w-5xl items-center gap-6 lg:grid-cols-2 lg:gap-10">
<motion.div
initial={reduceMotion ? false : { opacity: 0, y: 18 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 90, damping: 16 }}
>
<p className="inline-flex items-center gap-2 rounded-full border border-white/30 bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-widest text-white">
<span className="h-1.5 w-1.5 rounded-full bg-lime-300" />
Fernhill Cycles, Saturday rides
</p>
<h2 className="mt-5 text-4xl font-black leading-none tracking-tight sm:text-6xl">
Come close.
<br />
<span className="text-blue-200">It leans in.</span>
</h2>
<p className="mt-5 max-w-md text-base leading-7 text-blue-50">
Forty kilometres of gravel, a loaner bike in your size and coffee at the top. Move toward the button and it meets you halfway, then springs home when you leave.
</p>
<dl className="mt-7 flex gap-8 font-mono text-xs text-blue-100">
<div>
<dt className="uppercase tracking-widest text-blue-200">Radius</dt>
<dd className="mt-1 text-base font-semibold text-white">{RADIUS}px</dd>
</div>
<div>
<dt className="uppercase tracking-widest text-blue-200">Pull</dt>
<dd className="mt-1 text-base font-semibold text-white">{Math.round(STRENGTH * 100)}%</dd>
</div>
<div>
<dt className="uppercase tracking-widest text-blue-200">Spring</dt>
<dd className="mt-1 text-base font-semibold text-white">
{SPRING.stiffness} / {SPRING.damping}
</dd>
</div>
</dl>
</motion.div>
{/* The magnetic field. Everything inside this box tracks the pointer. */}
<div ref={zoneRef} className="relative flex h-80 touch-pan-y items-center justify-center">
<div aria-hidden="true" className="pointer-events-none absolute h-80 w-80 rounded-full bg-sky-300/40 blur-3xl" />
<motion.div
aria-hidden="true"
className="pointer-events-none absolute h-72 w-72 rounded-full border border-dashed border-white"
style={{ scale: ringScale, opacity: ringOpacity }}
/>
<div aria-hidden="true" className="pointer-events-none absolute h-44 w-44 rounded-full border border-white/20" />
<div aria-hidden="true" className="pointer-events-none absolute h-1.5 w-1.5 rounded-full bg-white/70" />
<motion.div aria-hidden="true" className="pointer-events-none absolute h-10 w-48 rounded-full bg-blue-950/50 blur-xl" style={{ x: shadowX, y: shadowY }} />
<motion.button
type="button"
aria-pressed={booked}
onClick={() => setBooked((value) => !value)}
style={{ x, y }}
initial={reduceMotion ? false : { opacity: 0, scale: 0.7 }}
animate={{ opacity: 1, scale: 1 }}
whileTap={reduceMotion ? undefined : { scale: 0.94 }}
transition={{ type: "spring", stiffness: 260, damping: 16, delay: 0.1 }}
className="relative inline-flex h-16 items-center rounded-full bg-white px-9 text-lg font-bold text-blue-700 shadow-xl shadow-blue-950/30 focus:outline-none focus-visible:ring-4 focus-visible:ring-lime-300"
>
<motion.span className="inline-flex items-center gap-3" style={{ x: labelX, y: labelY }}>
{booked ? "Saddle saved" : "Book a test ride"}
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-600 text-white">
{booked ? (
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className="h-4 w-4">
<path d="m5 10.5 3.2 3.2L15 6.8" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
) : (
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className="h-4 w-4">
<path d="M6 14 14 6m0 0H7.5M14 6v6.5" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</span>
</motion.span>
</motion.button>
{/* Demo pointer, hidden as soon as a real one arrives */}
{!reduceMotion && (
<motion.div
aria-hidden="true"
className="pointer-events-none absolute left-1/2 top-1/2"
style={{ x: ghostX, y: ghostY }}
animate={{ opacity: demo ? 1 : 0 }}
transition={{ duration: 0.25 }}
>
<svg viewBox="0 0 24 24" className="h-7 w-7 drop-shadow-lg">
<path d="M4 2.5 19.5 11l-6.6 1.7-2.6 6.6L4 2.5Z" fill="#0f172a" stroke="#ffffff" strokeWidth="1.6" strokeLinejoin="round" />
</svg>
</motion.div>
)}
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/magnetic-button-that-follows-the-pointer.json
A pointer listener on the surrounding zone measures the distance from the pointer to the button's resting centre. Inside the radius, the button's target offset is set to that distance multiplied by the strength. A spring then chases the target, which gives the lean in and the bounce back.
If only the button listened, the pull would begin when the pointer was already on top of it. Listening on a larger area lets the button react before it is touched, which is what makes it feel magnetic.
Yes. It uses pointer events, so pressing or dragging a finger inside the zone pulls the button. Vertical page scrolling still works because the zone allows it.
No. Only transforms change, through motion values that skip React renders. The demo loop stops when the section is off screen or the tab is hidden, and every listener is removed on unmount.
Yes. Move the motion values, the `pull` function and the listener into a small wrapper component and render one per button, each with its own zone.
Related: Shimmer Button with Light Sweep in Three Sizes, Button with Rotating Gradient Glow Border, Hold to Confirm Button with Progress Ring.
More animated buttons or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.