Magnetic Pointer Button
The element translates toward the pointer by a fraction of its offset from centre, and the label translates further still, so the text appears to lead the box it sits in. Release is an elastic overshoot rather than a linear return, which is the difference between "springy" and "sliding back".
Magnetic Pointer Button
Live preview
The real component, running in this page.
Install
Copies the source into your project. Nothing is imported from us at runtime.
npx shadcn@latest add https://kinetic-seven-taupe.vercel.app/r/magnetic-button.jsonPrompt
The whole technique, written for an agent.
Build it with your agent
A single self-contained brief: the technique, the constants and why they hold, the pitfalls, and the definition of done. Paste it and the component gets written in your own conventions instead of copied in ours.
Source
One file, exactly as installed.
API
Every parameter, its type and what happens when you change it.
| Parameter | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | — | Button label. Wrapped in the span that receives the parallax offset. |
| strength | number | = 0.35 | Fraction of the pointer offset the button travels. |
| labelStrength | number | = 1.6 | Label travel relative to the button. 1 disables parallax. |
| padding | number | = 24 | Pixels beyond the border that still count as near. |
| className | string | — | Merged onto the button element via cn. |
| onClick | () => void | — | Standard click handler. |
| disabled | boolean | = false | Disables the button and skips the effect entirely. |
How it works
The technique in order, not the code in order.
A window-level pointermove handler measures the pointer against the button's live bounding rect, not a cached one — the element is being translated by this same effect, so a rect captured on mount goes stale immediately.
Inside a hit area padded past the border, the offset from centre is scaled by
strengthand pushed into fourgsap.quickTosetters: x and y for the button, x and y for the label atlabelStrength - 1so it moves relative to its already-moving parent.Outside that area, a single
gsap.towithelastic.out(1, 0.4)andoverwrite: "auto"returns both nodes to zero, cancelling any in-flight follow tween.Cleanup kills tweens before clearing transforms, so nothing writes to the node after React releases it.
Load-bearing constants
| Constant | Value | Why this number |
|---|---|---|
| strength | = 0.35 | Fraction of pointer offset the button travels. Below ~0.2 the effect is invisible; above ~0.6 the button outruns the cursor and reads as a rendering fault rather than an affordance. |
| labelStrength | = 1.6 | The label moves 0.6x the button offset *on top of* the button's own movement. That parallax between text and border is what makes the surface feel elastic instead of rigid. |
| padding | = 24 | Pixels of grace beyond the border. Without it the effect engages exactly at the edge, and the button visibly jumps as it snaps into the field. |
| FOLLOW_DURATION | = 0.35 | quickTo duration. Short enough to feel attached to the cursor, long enough to smooth the 8-16 ms gaps between pointer events. |
| RETURN_DURATION | = 0.6 | Elastic return. Shorter than this and the overshoot has no room to read as a spring. |
Behaviour
What it does about motion sensitivity, teardown and being finished.
The effect never attaches when prefers-reduced-motion: reduce matches. The button keeps its colour transition on hover, so it still responds — it just does not move.
Removes the window listener, calls gsap.killTweensOf on both nodes, then clears the transform with gsap.set(..., { clearProps: "transform" }). Kill precedes clear so no in-flight tween can restore it.
Approach from any side and the button leans toward the cursor with the label leading it. Leave the padded area and it springs back past zero once before settling. Unmount mid-approach and no transform remains on the node. On a touch device it does not move at all.
Pitfalls
- Listening on the element instead of the window.
pointerleavefires the instant the button translates out from under the cursor, so it chases itself away and stutters along the edge of its own hit area. - Calling
gsap.to()per pointermove. That allocates a fresh tween ~120 times a second and each one fights the previous for the same property.quickToreuses one tween per property. - Caching the bounding rect on mount. The element moves and the page scrolls; a stale centre makes the button drift further away with every pass.
- Skipping
overwrite: "auto"on the return tween — the follow tween keeps running underneath and the button never actually reaches zero. - Leaving the transform behind on unmount. Clear it, but kill tweens first, or an in-flight tween writes it straight back.
Notes
- Gated behind
(pointer: fine). On touch there is no hover, so the effect would only fire on press, which reads as a glitch.