Build a Magnet Effect in React
Make elements attract toward the cursor within a defined radius, creating a tactile, physics-driven hover interaction.
Most hover effects are binary. On or off. The cursor is either over the element or it isn't. The Magnet effect breaks that rule by starting the interaction before the cursor arrives, pulling the element toward the mouse as it gets close.
The final result
What we are building
As the cursor enters a configurable radius around the element, the element moves toward the cursor. The closer the cursor gets, the more offset the element applies. When the cursor leaves the field, the element eases back to its resting position with a spring-like transition.
Setting up
No dependencies beyond React. The component uses CSS transform and transition strings directly.
interface MagnetProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
padding?: number;
disabled?: boolean;
magnetStrength?: number;
activeTransition?: string;
inactiveTransition?: string;
wrapperClassName?: string;
innerClassName?: string;
}Building the component
The core is a mousemove listener attached to the window. Every mouse movement triggers a check against the element's bounding rect:
const handleMouseMove = (e: MouseEvent) => {
if (!magnetRef.current) return;
const { left, top, width, height } = magnetRef.current.getBoundingClientRect();
const centerX = left + width / 2;
const centerY = top + height / 2;
const distX = Math.abs(centerX - e.clientX);
const distY = Math.abs(centerY - e.clientY);
if (distX < width / 2 + padding && distY < height / 2 + padding) {
setIsActive(true);
const offsetX = (e.clientX - centerX) / magnetStrength;
const offsetY = (e.clientY - centerY) / magnetStrength;
setPosition({ x: offsetX, y: offsetY });
} else {
setIsActive(false);
setPosition({ x: 0, y: 0 });
}
};The padding prop extends the bounding box beyond the element's edges, so the pull begins before the cursor is directly over it. The magnetStrength divisor controls how far the element moves. A higher value means less movement per pixel of cursor offset.
The actual animation is handled entirely by CSS transitions:
const transitionStyle = isActive ? activeTransition : inactiveTransition;
// inner div:
style={{
transform: `translate3d(${position.x}px, ${position.y}px, 0)`,
transition: transitionStyle,
willChange: 'transform',
}}Two transition strings lets you tune the follow feel and the return feel independently:
activeTransitiondefaults to'transform 0.3s ease-out'for a snappy follow.inactiveTransitiondefaults to'transform 0.5s ease-in-out'for a slower, springier return.
The full component structure is two nested divs: an outer wrapper that gets the ref for bounding rect calculations, and an inner div that gets the transform applied:
return (
<div
ref={magnetRef}
className={wrapperClassName}
style={{ position: 'relative', display: 'inline-block' }}
{...props}
>
<div
className={innerClassName}
style={{
transform: `translate3d(${position.x}px, ${position.y}px, 0)`,
transition: transitionStyle,
willChange: 'transform',
}}
>
{children}
</div>
</div>
);How to use it
<Magnet padding={80} magnetStrength={3}>
<button className="px-6 py-3 bg-white text-black rounded-full">
Get started
</button>
</Magnet>Key takeaways
- Using the window for
mousemoveinstead of the element itself means the magnetic field works even when the cursor has not yet touched the element. - Dividing cursor offset by
magnetStrengthkeeps the movement proportional. A value of 2 means the element moves half as far as the cursor does, which feels natural. - The
disabledprop resets position to zero and skips the event listener, making it easy to turn the effect off on mobile or for reduced-motion preferences.