Magnetic Hover Button Effect in React: Cursor Attraction Physics
Build a magnetic button that pulls toward your cursor using React refs, mouse events, and spring physics — no heavy libraries required.
What a Magnetic Button Actually Does
You've seen it on agency portfolios and SaaS landing pages: you move your cursor near a button and it slides toward you, like it's on a string. That's the magnetic button effect. It's a microinteraction that's been floating around since roughly 2019 but hit its peak popularity in 2024–2025 once Framer and similar no-code tools made it dead simple to apply. The question is whether you want to pull in a full dependency or wire it up yourself in 30-odd lines of React.
The core physics is straightforward. You track the cursor position relative to the button's bounding box. When the cursor is within a defined radius — say 80px — you translate the button element toward the cursor by a fraction of that distance. When the cursor leaves the zone, you spring it back to 0,0. That's it. No WebGL, no canvas, no GSAP required (though GSAP makes it nicer, which we'll cover later).
Honestly, most implementations you'll find on CodePen overcomplicate this. They recalculate bounding rects on every mouse move, they apply transforms through inline styles on every frame, and they don't clean up listeners. By the end of this article you'll have a version that's clean, reusable, and won't tank your Lighthouse score.
Worth noting: this effect works best on large, isolated CTAs — think hero buttons, floating action buttons, or nav items. Don't apply it to 12 buttons in a form. The magic disappears when it's everywhere.
The Math Behind the Pull
Before writing any React, you need to understand the transform calculation. You have a button centered at some point on screen. The cursor is at (mouseX, mouseY). You want the button to move a percentage of the distance between the cursor and the button's center.
const rect = button.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const distanceX = mouseX - centerX;
const distanceY = mouseY - centerY;
// Move 30% of the way toward the cursor
const translateX = distanceX * 0.3;
const translateY = distanceY * 0.3;The 0.3 multiplier is your strength factor. Higher values feel more aggressive. Values above 0.6 start to look broken — the button basically follows your cursor. Values around 0.2–0.35 feel subtle and intentional. You can expose this as a prop.
The radius check is the other piece. You don't want the button pulling from across the page — only when the cursor is nearby. A dead zone of 80px–120px works well for a standard 44px-tall button. Beyond that radius, don't apply any transform.
const distance = Math.sqrt(distanceX ** 2 + distanceY ** 2);
const maxRadius = 120; // px
if (distance < maxRadius) {
applyTransform(translateX, translateY);
} else {
resetTransform();
}In practice, the hard cutoff at maxRadius feels jarring. You'll want to ease the strength off as the cursor approaches the edge of the zone, which we'll handle with a smooth transition.
Building the React Hook
Let's write a useMagneticEffect hook that you can drop onto any element. The hook attaches mouse move and mouse leave listeners to the document rather than the element itself — this way you catch cursor positions even when the cursor is slightly outside the element bounds.
import { useRef, useEffect, useState } from 'react';
type MagneticOptions = {
strength?: number;
radius?: number;
};
export function useMagneticEffect<T extends HTMLElement>(
options: MagneticOptions = {}
) {
const { strength = 0.3, radius = 100 } = options;
const ref = useRef<T>(null);
const frameRef = useRef<number>(0);
const posRef = useRef({ x: 0, y: 0 });
const targetRef = useRef({ x: 0, y: 0 });
useEffect(() => {
const el = ref.current;
if (!el) return;
const handleMouseMove = (e: MouseEvent) => {
const rect = el.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
const dx = e.clientX - cx;
const dy = e.clientY - cy;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < radius) {
const scale = (1 - dist / radius);
targetRef.current = {
x: dx * strength * scale,
y: dy * strength * scale,
};
} else {
targetRef.current = { x: 0, y: 0 };
}
};
const handleMouseLeave = () => {
targetRef.current = { x: 0, y: 0 };
};
const animate = () => {
const lerp = 0.15;
posRef.current.x += (targetRef.current.x - posRef.current.x) * lerp;
posRef.current.y += (targetRef.current.y - posRef.current.y) * lerp;
el.style.transform =
`translate(${posRef.current.x.toFixed(2)}px, ${posRef.current.y.toFixed(2)}px)`;
frameRef.current = requestAnimationFrame(animate);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseleave', handleMouseLeave);
frameRef.current = requestAnimationFrame(animate);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseleave', handleMouseLeave);
cancelAnimationFrame(frameRef.current);
};
}, [strength, radius]);
return ref;
}The lerp (linear interpolation) at 0.15 is what makes the motion feel springy rather than instant. Each frame, the current position moves 15% closer to the target. Higher lerp = snappier. Lower lerp = floatier. This is the single most impactful tuning knob for feel.
Notice we're using requestAnimationFrame for the animation loop rather than updating state on every mouse move. This is critical. State updates in React trigger re-renders. You absolutely do not want a re-render on every pixel of mouse movement — that's a performance disaster. Instead, we write directly to el.style.transform and skip React's render cycle entirely for the animation.
One more thing — the (1 - dist / radius) scale factor is what creates the gradual pull. At the edge of the radius zone, strength is near zero. At the center, it's at full strength. This removes the jarring cutoff problem we noted earlier.
The Button Component
With the hook written, the actual component is almost trivial.
import { useMagneticEffect } from './useMagneticEffect';
type MagneticButtonProps = {
children: React.ReactNode;
strength?: number;
radius?: number;
className?: string;
onClick?: () => void;
};
export function MagneticButton({
children,
strength = 0.3,
radius = 100,
className = '',
onClick,
}: MagneticButtonProps) {
const ref = useMagneticEffect<HTMLButtonElement>({ strength, radius });
return (
<button
ref={ref}
onClick={onClick}
className={`magnetic-btn ${className}`}
style={{ willChange: 'transform', display: 'inline-block' }}
>
{children}
</button>
);
}The will-change: transform hint tells the browser to promote this element to its own compositor layer. That means the GPU handles the transform updates without repainting the rest of the page. For an effect that's running at 60fps, this matters. You'd be surprised how often this detail gets skipped.
Look, you can style this however you want. Pair it with a glassmorphism card style, a neobrutalism border, whatever fits your design system. The hook doesn't care about appearance — it only touches transform. That's good separation of concerns.
Quick aside: if you're using Tailwind, add will-change-transform and inline-block to the className instead of inline styles. The result is the same.
Adding a Spring with Framer Motion
The lerp-based approach above is good. The Framer Motion spring approach is better. If you're already using Framer Motion in your project — and in 2026 most React projects are — you get real physics instead of linear interpolation.
import { motion, useMotionValue, useSpring } from 'framer-motion';
import { useRef, useEffect } from 'react';
export function SpringMagneticButton({
children,
className = '',
}: {
children: React.ReactNode;
className?: string;
}) {
const ref = useRef<HTMLButtonElement>(null);
const rawX = useMotionValue(0);
const rawY = useMotionValue(0);
const x = useSpring(rawX, { stiffness: 300, damping: 20, mass: 0.5 });
const y = useSpring(rawY, { stiffness: 300, damping: 20, mass: 0.5 });
useEffect(() => {
const el = ref.current;
if (!el) return;
const handleMove = (e: MouseEvent) => {
const rect = el.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
const dx = e.clientX - cx;
const dy = e.clientY - cy;
const dist = Math.sqrt(dx * dx + dy * dy);
const radius = 100;
if (dist < radius) {
rawX.set(dx * 0.3 * (1 - dist / radius));
rawY.set(dy * 0.3 * (1 - dist / radius));
} else {
rawX.set(0);
rawY.set(0);
}
};
document.addEventListener('mousemove', handleMove);
return () => document.removeEventListener('mousemove', handleMove);
}, [rawX, rawY]);
return (
<motion.button
ref={ref}
style={{ x, y, willChange: 'transform' }}
className={className}
>
{children}
</motion.button>
);
}The spring config { stiffness: 300, damping: 20, mass: 0.5 } gives a nice overshoot on the return that the lerp version can't replicate. Lower damping means more bounce. Lower stiffness means slower response. Playing with these three values for 10 minutes will teach you more about spring physics than any tutorial.
That said, if you're not already using Framer Motion, don't add it just for this effect. The bundle cost isn't worth it for one component. Stick with the requestAnimationFrame hook instead.
You can also combine this with custom cursors — when the magnetic effect pulls the button toward the cursor, a custom cursor that scales up on hover creates a really satisfying combined effect. The two interactions complement each other naturally.
Performance Considerations and Mobile
A magnetic button that runs poorly defeats the entire purpose. A few things to keep in mind. First, getBoundingClientRect() reads from the DOM and can cause layout reflow if the browser hasn't finished its layout pass. Caching the rect and only updating it on scroll or resize (not on every mouse move) is worth doing if you have many magnetic elements on one page.
// Cache rect, update on resize/scroll only
useEffect(() => {
let cachedRect = el.getBoundingClientRect();
const updateRect = () => {
cachedRect = el.getBoundingClientRect();
};
window.addEventListener('resize', updateRect, { passive: true });
window.addEventListener('scroll', updateRect, { passive: true });
// Use cachedRect inside your mousemove handler
return () => {
window.removeEventListener('resize', updateRect);
window.removeEventListener('scroll', updateRect);
};
}, []);Mobile is the elephant in the room. Touch screens don't have hover events. The magnetic effect simply won't fire. That's actually fine — you don't want it to, because touch targets should stay where users tap them. You should conditionally apply the effect based on pointer type. The matchMedia('(hover: hover)') check is your friend here. If it returns false, skip attaching the listeners entirely.
const hasHover = window.matchMedia('(hover: hover)').matches;
if (!hasHover) return; // skip magnetic setupAlso respect prefers-reduced-motion. Users who have that setting enabled are telling you they don't want motion effects. Skip the transform entirely for them. This is a one-liner check and it's the right thing to do — check the accessibility guide for a deeper look at motion accessibility patterns.
Going Further: Magnetic Text and Multi-Axis Rotation
Once you have the basic magnetic pull working, the next obvious upgrade is adding a subtle 3D rotation based on cursor position. Instead of just translating, you rotate the button on the X and Y axes to create a tilt effect that tracks the cursor. This pairs especially well with glossy or glassmorphic button styles from the glassmorphism generator.
// Inside your animation loop or Framer Motion setup:
const rotateX = -dy * 0.05; // tilt up/down
const rotateY = dx * 0.05; // tilt left/right
el.style.transform = [
`translate(${tx.toFixed(2)}px, ${ty.toFixed(2)}px)`,
`rotateX(${rotateX.toFixed(2)}deg)`,
`rotateY(${rotateY.toFixed(2)}deg)`,
].join(' ');For the rotation to look 3D, the parent needs perspective set. Something like perspective: 600px on the wrapper element is usually enough. Too low and the distortion looks extreme; too high and it disappears.
Another direction is making the button label text move independently from the button background — so the button container shifts 30% toward the cursor, but the text inside shifts 50%, creating a parallax within the button. This adds another layer of depth and the implementation is just two refs and two separate transform calculations. It's a small addition with a large perceived quality boost.
If you want to see polished examples of these effects in context, the gradient generator and box shadow generator tools use similar cursor-reactive elements. Worth browsing for visual inspiration before you start styling your own version.
FAQ
No, and it shouldn't. Mobile has no mouse events, so the effect simply never triggers. Use window.matchMedia('(hover: hover)') to detect pointer devices and skip the setup on touch screens. Your touch tap targets stay exactly where users expect them.
It can. Each button runs its own requestAnimationFrame loop. For three or four buttons it's fine. For a dozen, consider a single global mouse move handler that updates all elements in one loop. Also cache getBoundingClientRect() on resize rather than recalculating it on every mouse event.
No. The requestAnimationFrame + lerp approach works perfectly and adds zero bytes to your bundle. Framer Motion gives you real spring physics with overshoot, which feels better, but only makes sense if you're already using it.
Start with strength: 0.3 and radius: 100. These feel subtle enough not to be distracting but noticeable enough to register. Increase strength toward 0.5 for hero CTAs where you want maximum drama. Keep radius between 80px–140px — smaller feels too abrupt, larger starts feeling weird.