Magnetic Button
The effect is not really about the button moving. It is about the invisible hit area around it — the part that makes the cursor feel pulled in before it ever touches the edge. Most implementations miss this and end up with a button that only reacts once you are already on top of it.
Live demo
Move your cursor slowly across the whole area, not just the button.
The dashed outline is the hit area (≈120px of padding). The button is only the visible part.
The prompt
Make the primary call-to-action button magnetic. Behaviour: - Wrap the button in a container with about 60px of invisible padding on all sides. Attach the pointer listeners to that wrapper, not to the button, so the effect begins before the cursor reaches the button. - On pointermove, compute the cursor offset from the wrapper centre, multiply by 0.3, and write the result into two CSS custom properties (--mx, --my) on the button. - The button uses transform: translate(var(--mx), var(--my)) with a 0.35s cubic-bezier(.22,1,.36,1) transition for the return, and a 0.08s linear transition while the pointer is inside. - On pointerleave, reset both custom properties to 0px. - Only transform the button. Never animate margin, top or left. - Guard the whole effect with @media (hover: hover) and (pointer: fine) so touch devices get a normal button. - Cap the strength at 0.3 — anything above 0.4 makes the target feel unstable. - Keep the focus ring intact and do not move the button further than 16px, or keyboard users lose sight of the hit target.
What actually makes it work
- Listen on the padded wrapper. This is the whole trick. A 60px padding ring means the pointer is already inside a listening area while still visually outside the button — that gap is what reads as magnetism. Listening on the button itself produces a much weaker effect.
- Write CSS variables, not inline transforms. Setting
--mxand--mykeeps the transform in the stylesheet, so the return animation is defined once and the JS stays stateless. - Two transition speeds. Fast while the pointer is inside (tracking feels responsive), slow on exit (the spring-back reads as weight). A single speed feels either laggy or rubbery.
- Offset from the centre, not from the button origin. Subtracting half the width and height is what makes the button move toward the cursor rather than away.
Compatibility and performance
| Environment | Behaviour |
|---|---|
| Desktop with mouse | Full effect. Pointer events are universal, no polyfill needed. |
| Touch devices | Effect disabled by the hover media query — a normal button remains. |
| Keyboard | Unchanged. pointerleave never fires, so the button stays in place. |
The cost here is negligible: one pointer event per frame per button, a style write on a custom property, and a compositor-only transform. The thing to watch is pointermove frequency — it fires at the device's report rate (often 120Hz+). Because the handler does no layout reads, that is fine; if you ever need the bounding rect, cache it on pointerenter instead of calling getBoundingClientRect() inside the move handler.
When not to use it
Don't apply it to every button. One magnetic element per screen is a deliberate emphasis. Four of them and the page feels twitchy.
Don't use it on the only route to conversion if you can't test it. A button that moves away from a shaky cursor can cost clicks. Cap the displacement and always keep the hit area at least as large as the original button.
Don't stack it with a cursor-following custom cursor. Two things chasing the pointer at once reads as a bug, not a flourish.