Magnetic Button Hover Effect: CSS + JS Cursor-Following Animation
Build a magnetic button hover effect that follows the cursor with vanilla JS and CSS transforms. Includes React component, Tailwind v4 classes, and spring-easing tips.
What Is a Magnetic Button Hover Effect?
Honestly, magnetic buttons are one of those effects that designers have been obsessing over for the last few years — and for good reason. The idea is simple: when your cursor gets close to a button, the button physically moves toward the cursor. It snaps back when you leave. That's it.
It's a classic portfolio trick from the agency world, but it's started showing up in SaaS dashboards and landing pages because it genuinely increases click-through on primary CTAs. Not because of magic — because it draws attention and invites interaction in a way that static hover states don't.
The effect requires two things: a way to track cursor position relative to the button, and a CSS transform that shifts the button element (not the whole page) toward that position. You can wire this up in about 40 lines of JavaScript. No library required, though we'll look at a React version too.
How the Cursor-Tracking Math Works
The math isn't complicated once you see it. When the user's mouse moves, you grab the cursor's X and Y coordinates. You also grab the button's bounding rectangle — its center point in the viewport. The offset is just the difference between cursor position and button center.
You don't want the button to follow the cursor 1:1 — that's too aggressive and looks broken. A strength multiplier of 0.35 to 0.5 gives you a nice subtle pull. So if the cursor is 60px to the right of center, the button shifts 60 * 0.4 = 24px to the right. Then you reset the transform to translate(0, 0) when the mouse leaves the proximity zone.
The proximity zone matters. You don't want the effect triggering when the cursor is 400px away. A threshold of 80px to 120px from the button's edge works well. Calculate that with getBoundingClientRect() — compare the cursor distance to the button center against your threshold value before applying any transform.
Vanilla JS Implementation
Here's the core implementation in plain JavaScript. No dependencies, no framework. Drop this onto any button with the data-magnetic attribute and it works.
const THRESHOLD = 100; // px from button edge that triggers the effect
const STRENGTH = 0.4; // how far the button follows (0–1)
document.querySelectorAll('[data-magnetic]').forEach((btn) => {
btn.addEventListener('mousemove', (e) => {
const rect = btn.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const distX = e.clientX - centerX;
const distY = e.clientY - centerY;
const distance = Math.sqrt(distX ** 2 + distY ** 2);
if (distance < THRESHOLD) {
const moveX = distX * STRENGTH;
const moveY = distY * STRENGTH;
btn.style.transform = `translate(${moveX}px, ${moveY}px)`;
}
});
btn.addEventListener('mouseleave', () => {
btn.style.transform = 'translate(0px, 0px)';
});
});One thing to note: you'll want CSS transition on the button to smooth the snap-back. Something like transition: transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) works well. The cubic-bezier gives it that satisfying spring feel on release without a full spring physics library.
React Component with useRef and useCallback
For React, we want to avoid re-renders on every mousemove event. Using useRef to mutate the DOM directly — bypassing React state — is the right call here. This keeps the animation at 60fps without any reconciliation overhead.
import { useRef, useCallback } from 'react';
const STRENGTH = 0.4;
const THRESHOLD = 100;
interface MagneticButtonProps {
children: React.ReactNode;
className?: string;
onClick?: () => void;
}
export function MagneticButton({ children, className = '', onClick }: MagneticButtonProps) {
const btnRef = useRef<HTMLButtonElement>(null);
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
const btn = btnRef.current;
if (!btn) return;
const rect = btn.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const distX = e.clientX - centerX;
const distY = e.clientY - centerY;
const distance = Math.sqrt(distX ** 2 + distY ** 2);
if (distance < THRESHOLD) {
btn.style.transform = `translate(${distX * STRENGTH}px, ${distY * STRENGTH}px)`;
}
}, []);
const handleMouseLeave = useCallback(() => {
if (btnRef.current) {
btnRef.current.style.transform = 'translate(0px, 0px)';
}
}, []);
return (
<button
ref={btnRef}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onClick={onClick}
className={`transition-transform duration-300 ease-out ${className}`}
style={{ willChange: 'transform' }}
>
{children}
</button>
);
}The willChange: 'transform' hint tells the browser to promote this element to its own compositing layer. Combined with GPU-accelerated transforms, you'll rarely see jank even on lower-end machines. Don't slap will-change on every element though — it does consume memory.
Styling With Tailwind v4 and CSS Variables
With Tailwind v4.0.2, you can build out the visual shell of the magnetic button entirely with utility classes. The key is making sure you don't fight the transition property — Tailwind's transition-transform sets the CSS transition-property: transform specifically, which is what you want. Adding duration-300 and ease-out covers the timing.
If you're adding a glow or shadow on hover, CSS custom properties work better here than Tailwind's built-in shadow utilities, because you can animate the opacity of the glow independently. Something like --glow-opacity: 0 in the default state and --glow-opacity: 0.6 on hover, combined with a box-shadow: 0 0 32px 8px rgba(139, 92, 246, var(--glow-opacity)) gives you a controllable purple glow without touching the transform chain.
If you're pairing this with animated backgrounds — like particles background or an aurora effect — the magnetic button reads better with a semi-transparent background at roughly rgba(255,255,255,0.15) and a backdrop-filter: blur(12px). That glassmorphism shell lets the background animation show through while keeping the button legible. For a deeper look at that pattern, check what is glassmorphism.
Adding a Spring Easing for the Follow Motion
The mousemove handler fires at up to 60 times per second, which means the transform jumps discretely to the new position every frame. That's actually fine for the follow motion — it looks responsive. But the snap-back on mouseleave benefits enormously from a spring curve.
CSS cubic-bezier can approximate a spring but can't overshoot (values above 1.0 on the Y axis do overshoot in some browsers but it's inconsistent). If you want a real spring on the snap-back, you can use a small WAAPI animation: btn.animate([{ transform: currentTransform }, { transform: 'translate(0px, 0px)' }], { duration: 600, easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)', fill: 'forwards' }). The 1.56 value there causes overshoot — that little bounce past zero and back is what makes it feel physical.
Is this overkill? For a marketing site's hero CTA, no. For a dashboard with 40 buttons visible at once, probably yes. Know your context. The vanilla CSS transition version with cubic-bezier(0.25, 0.46, 0.45, 0.94) is perfectly good for 95% of use cases and costs you nothing extra.
Accessibility and Reduced Motion
Here's the thing: any motion effect needs a reduced-motion fallback. Users with vestibular disorders can find cursor-following animations genuinely nauseating. The prefers-reduced-motion media query exists exactly for this.
In CSS: wrap your transition in a @media (prefers-reduced-motion: no-preference) block so it only applies when the user hasn't requested reduced motion. In the JS side, check window.matchMedia('(prefers-reduced-motion: reduce)').matches before attaching your mousemove handlers at all. If it returns true, just skip the whole effect — the button works fine without it.
The ARIA side is simpler: magnetic buttons don't need any special ARIA roles. They're still <button> elements. Focus states should still be visible — don't let the transform styling accidentally clip your focus ring. Add outline-offset: 4px to keep the outline clear of the button's border on all transform states. Also worth checking how this interacts with your theme toggle if you're running light and dark modes — focus ring contrast requirements differ between themes.
Performance: What to Watch Out For
The mousemove event fires constantly while the cursor is inside the element. At 60fps on a modern monitor, that's potentially 60 DOM reads per second for getBoundingClientRect(). Each call forces a layout read, which can cause forced reflows if you're reading and writing layout in the same frame.
The fix is simple: cache the bounding rect on mouseenter, not on every mousemove. The button isn't resizing while you hover it (in most cases), so you only need one read. Store it in a ref or a closure variable. On mouseleave, clear it. This cuts your layout reads from 60/s to 1 per hover interaction.
Also: requestAnimationFrame throttling is sometimes recommended for mousemove handlers, but in practice for a simple transform mutation it adds more complexity than it removes. Modern browsers batch style mutations efficiently. If you're doing heavier work inside the handler — like updating multiple elements or running expensive calculations — then yes, throttle with rAF. For a single style.transform assignment, don't bother.
FAQ
No — mousemove events don't fire on touch screens. You'd need to listen to touchmove events separately and replicate the logic. Most implementations skip the magnetic effect on touch and just fall back to a standard active state. Since mobile users can't hover anyway, it's not a meaningful UX loss.
You're probably missing position: relative on a wrapper element. The button's transform moves it visually but doesn't affect layout flow — however if you haven't isolated it properly, siblings can sometimes shift. Wrap the button in a <div style={{ display: 'inline-block', padding: '20px' }}> to give the magnetic movement room without affecting adjacent elements.
Between 0.3 and 0.5 for most cases. Below 0.3 it's barely noticeable. Above 0.6 it starts feeling unstable and hard to click. The sweet spot is 0.4 — the button moves visibly but the cursor still clearly lands on it without chasing.
Yes. The same logic works on any element. The main caveat is that larger elements need a smaller STRENGTH value — a card that's 300px wide moving by 40% of cursor offset will look chaotic. For cards, try 0.1 to 0.2. Also consider only translating the inner content of the card, not the whole card including its shadow/border.
Use useMotionValue and useSpring from framer-motion instead of directly setting style.transform. Create x and y motion values, apply spring configs like { stiffness: 300, damping: 20 }, then set them in your mousemove handler. Bind them to the element via style={{ x, y }}. This gives you real spring physics instead of CSS easing approximations.
Not directly. The effect is purely visual and JavaScript-driven after page load, so it doesn't affect LCP or CLS (assuming you've wrapped it correctly to prevent layout shifts). The small JS overhead is negligible. Where it can indirectly help is engagement metrics — higher interaction rates on a CTA can reduce bounce rate.