Magnetic Button
The button slides toward the cursor (30% of its offset from center) and snaps back in 300 ms — a CTA that answers the hand before the click.
Updated
This effect is part of Effect.Labs — 840 vanilla effects, some free, some premium. Buttons has 27 effects, including 2 free. Explore the category →
3 usage examples



How it works
The script attaches a mousemove listener to every element carrying the data-magnetic attribute. On each event, getBoundingClientRect() returns the button's position and size, then two offsets are computed: a = clientX − left − width/2 and l = clientY − top − height/2 — the cursor position relative to the center, in pixels.
The shift is written as an inline style: transform: translate(0.3·a px, 0.3·l px). The button therefore travels 30% of the way toward the cursor (slightly less in practice: the rect is re-read on the already-shifted button, so the measured offset is shorter). Maximum travel is 0.3 × half-size: ±30 px horizontally and ±8 px vertically for a 200 × 52 px button.
Smoothing is pure CSS: transition: transform .3s on .btn-magnetic (default ease curve). Each new target is reached in 300 ms while the cursor moves faster — hence the feeling of a button being pulled, lagging behind the hand. On mouseleave the script writes translate(0, 0) and the same transition brings the button home.
Selecting by attribute (querySelectorAll('[data-magnetic]')) makes the effect multi-instance with no ids: every marked button is wired in one pass. The listener sits on the button itself, not on a zone around it: attraction only starts once the cursor touches the button (see the FAQ for a wider attraction zone).
Accessibility
- prefers-reduced-motion not handled: the button moves on every hover regardless of the system preference. Add
@media (prefers-reduced-motion: reduce) { .btn-magnetic { transition: none; transform: none !important } }— the!importantis required because the JS writes thetransforminline — or testmatchMedia('(prefers-reduced-motion: reduce)').matchesbefore attaching the listeners. - The effect is mouse-only (
mousemove/mouseleave). With keyboard or touch, the button is a plain native<button>: nothing is lost, the movement is decoration. - Insufficient contrast as shipped: white text on the cyan → blue gradient measures 2.4:1 on the
#06b6d4end and 3.7:1 on the#3b82f6end, below the 4.5:1 AA threshold for 16 px text. Darken the gradient (#0e7490 → #1d4ed8gives 5.4:1 and 6.7:1) or set the label at 18 px bold (3:1 threshold). - The code sets
border: nonebut leavesoutlinealone: the native focus ring stays visible when tabbing. Don't remove it; if you restyle it, use:focus-visiblewith a contrasting 2 px ring. - Touch target ≈ 52 px tall (16 px padding + 16 px text), above the 44 px minimum. Screen readers: native
<button>with a text label, no ARIA needed; the inlinetransformdoesn't alter the accessibility tree.
Browser compatibility
Requires getBoundingClientRect, ES2015 arrow functions and template literals, and a CSS transform transition — every browser since 2016. Zero dependencies.
Without JavaScript, the button renders and works normally, gradient and rounded corners included; only the movement is gone. No error if the page has no data-magnetic element: the forEach loop is simply empty.
The code
HTML structure to paste into your page (the CSS + JS come with Premium):
<button class="demo-btn btn-magnetic" data-magnetic>Magnetic</button>
The full HTML + CSS + JS of Magnetic Button, ready to paste — plus all 840 effects. Or $299 once, lifetime access.
Customize
Options passed to the API or data-* attributes:
| Option / property | Default | Effect |
|---|---|---|
Coefficient .3 (JS — translate(${.3*a}px, ${.3*l}px)) |
.3 | Fraction of the cursor/center offset applied to the button. 0.15 = a quiver; 0.5 = the button travels half the way and visibly leaves its slot. |
| transition: transform .3s (CSS — .btn-magnetic) | .3s ease | Follow latency. .1s sticks to the cursor (twitchy); .5s gives a heavy motion. The return on mouseleave uses the same duration. |
[data-magnetic] selector (JS) |
valueless attribute | Put the attribute on any element (link, card, icon) to magnetize it — the script assumes nothing about the tag. Remove it to disable one button without touching the JS. |
| background (CSS — .btn-magnetic) | linear-gradient(135deg, #06b6d4, #3b82f6) | Cyan → blue gradient. Swap in your colors; for AA contrast with white text, stay on dark shades (#0e7490 → #1d4ed8). |
| padding / font-size (CSS — .demo-btn) | 16px 32px / 1rem | Button size — hence maximum travel, computed from width/2 and height/2. A button twice as wide moves twice as far. |
| border-radius (CSS — .demo-btn) | 12px | Corner shape. 999px gives a pill; the movement doesn't depend on the shape. |
Rest state (JS — mouseleave) |
translate(0, 0) | Write translate(0, 0) scale(1) here and add scale(1.05) to the mousemove string to combine attraction with a slight zoom. |
FAQ
mousemove is listened to on the button itself, so the effect starts on contact. For an attraction zone, wrap the button in a larger <div> (e.g. padding: 40px), put data-magnetic on that parent, and apply the transform to the child: replace e.style.transform with e.firstElementChild.style.transform in both listeners.addEventListener calls into a useEffect / onMounted with a ref to the button, and remove them on unmount (removeEventListener). The math stays the same.transform is outside the layout flow, neighbors don't move and no reflow is triggered on them. Just leave a margin at least equal to the maximum travel (0.3 × half-width) so the button never overlaps a neighbor, and avoid a parent with overflow: hidden that would clip it.