The "magnetic button" is one of the most recognizable micro-interactions in modern web design. As the cursor approaches a call-to-action, the button slides toward the pointer as if pulled by gravity. When the cursor leaves, it snaps back.
The effect feels great and gives immediate feedback, but it relies on real-time spatial tracking - pure CSS cannot do this.
Here is how the magnetic hover effect works, built with Vanilla JavaScript and CSS custom properties.
The Core Logic: Spatial Tracking
To make an element magnetic, the browser needs to continuously calculate the distance between the button's center and the cursor.
We do this by attaching a mousemove event listener to the button (or a larger wrapper around it). On every movement tick, we query the element's position with getBoundingClientRect().
Subtracting the bounding box coordinates from the viewport coordinates (clientX, clientY) gives us the cursor's position relative to the button.
The Vanilla JavaScript Implementation
In a standard Vanilla JS environment, we want to inject these calculated relative coordinates directly into the DOM as inline CSS custom properties.
Here is the conceptual flow:
- Listen for movement: Attach
mousemoveandmouseleavelisteners. - Calculate distance: Find the center of the element. Compare it to the mouse position.
- Apply the offset: Set CSS variables
--xand--yon the element's style. - Reset: On
mouseleave, reset--xand--yto zero.
const button = document.querySelector('.magnetic-button');
button.addEventListener('mousemove', (e) => {
const rect = button.getBoundingClientRect();
// Calculate cursor position relative to the element's center
const x = e.clientX - rect.left - rect.width / 2;
const y = e.clientY - rect.top - rect.height / 2;
// Apply a dampening factor (e.g., 0.3) so it doesn't move 1:1 with the mouse
button.style.setProperty('--x', `${x * 0.3}px`);
button.style.setProperty('--y', `${y * 0.3}px`);
});
button.addEventListener('mouseleave', () => {
button.style.setProperty('--x', '0px');
button.style.setProperty('--y', '0px');
});
The CSS: Smooth Transitions
The JavaScript handles the math, but the CSS handles the actual visual movement. We consume the --x and --y variables within a transform: translate() function.
Crucially, we need a CSS transition to ensure the movement feels fluid and spring-like, rather than instantly snapping to the exact pixel.
.magnetic-button {
/* Default position variables */
--x: 0px;
--y: 0px;
transform: translate(var(--x), var(--y));
/* The transition provides the "spring" feel on release */
transition: transform 0.2s cubic-bezier(0.25, 1, 0.5, 1);
/* Safe for a single element. For grids of many items,
apply will-change dynamically on :hover instead. */
will-change: transform;
}
/* Optional: Remove transition during active movement for snappier tracking */
.magnetic-button:hover {
transition: transform 0.05s linear;
}
The React Approach
In React, the instinct is to store the coordinates in state. Don't. Binding a mousemove handler to useState triggers a re-render on every pixel of movement, which buries the main thread.
Instead, use useRef to grab the DOM node and call ref.current.style.setProperty directly - the same direct-mutation approach as the Vanilla JS version above.
For smoother spring physics, Framer Motion provides useMotionValue and useSpring. These track coordinates reactively without triggering React re-renders.
Get the Full Code
Building this from scratch is a solid exercise, but for production you want code that handles edge cases (touch devices where hover doesn't apply, cleanup of event listeners, accessibility).
We have fully optimized, copy-paste versions of the Magnetic Card in both Vanilla JS and React + Tailwind in the Hover Effect Grid. Preview the effect live and grab the snippet for your next project.