Magnetic Button
A button that subtly pulls toward the cursor using pointer tracking and CSS custom properties.
Interactive demo
What it does
A button that subtly shifts toward the cursor on hover or pointer movement, adding tactile feedback without leaving its layout box.
When to use it
- Primary call-to-action buttons that deserve emphasis.
- Icon buttons in hero sections or navigation.
- Interactive controls where playful motion reinforces affordance.
When to avoid it
- Dense UIs where small shifts could feel jittery.
- Buttons near scroll edges where overflow could clip the effect.
- Users with vestibular disorders unless disabled by reduced motion.
How it works
- Track pointer position relative to the button center on pointermove.
- Calculate offset as a fraction of the button size, clamped to a small range.
- Update CSS custom properties
--mxand--myon the button. - Apply transform — translate the button by the offset using CSS.
- Reset on leave — return the button to 0,0 with a springy transition.
- Reduced motion — skip position updates and keep the button static.
Source code
<button type="button" class="magnetic-button" data-magnetic>
<span class="magnetic-button__text">Magnetic</span>
</button> :root {
color-scheme: dark;
--bg: #0a0a0b;
--text: #f4f4f5;
--accent: #8b5cf6;
--accent-light: #a78bfa;
}
body {
margin: 0;
min-height: 100dvh;
display: grid;
place-items: center;
font-family: system-ui, sans-serif;
background: var(--bg);
color: var(--text);
}
.magnetic-button {
--mx: 0px;
--my: 0px;
padding: 1rem 2.5rem;
font-size: 1rem;
font-weight: 600;
color: white;
background: linear-gradient(135deg, var(--accent), var(--accent-light));
border: none;
border-radius: 9999px;
cursor: pointer;
transform: translate(var(--mx), var(--my));
transition: transform 120ms ease-out;
will-change: transform;
}
.magnetic-button__text {
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.magnetic-button {
transform: none;
transition: none;
}
} function prefersReducedMotion(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
const button = document.querySelector<HTMLElement>("[data-magnetic]");
if (button && !prefersReducedMotion()) {
const maxOffset = 12;
button.addEventListener("pointermove", (event) => {
const rect = button.getBoundingClientRect();
const x = event.clientX - rect.left - rect.width / 2;
const y = event.clientY - rect.top - rect.height / 2;
const offsetX = (x / (rect.width / 2)) * maxOffset;
const offsetY = (y / (rect.height / 2)) * maxOffset;
button.style.setProperty("--mx", `${offsetX}px`);
button.style.setProperty("--my", `${offsetY}px`);
});
button.addEventListener("pointerleave", () => {
button.style.setProperty("--mx", "0px");
button.style.setProperty("--my", "0px");
});
}
export {}; Implementation recipe
Magnetic Button Implementation Recipe
Goal
Create a button that subtly pulls toward the cursor using pointer tracking and CSS custom properties.
Requirements
- A button element.
- Pointer position relative to the button center.
- CSS custom properties to drive the transform.
- Reduced-motion fallback.
Files to create
index.htmlstyles.cssmagnetic.ts
Step 1: Markup
Use a <button> so the element remains accessible and keyboard-focusable.
Step 2: Define custom properties
Expose --mx and --my on the button and use them in transform.
.magnetic-button {
--mx: 0px;
--my: 0px;
transform: translate(var(--mx), var(--my));
transition: transform 120ms ease-out;
}
Step 3: Track pointer movement
On pointermove, calculate the cursor distance from the button center normalized to a small range.
const rect = button.getBoundingClientRect();
const x = event.clientX - rect.left - rect.width / 2;
const y = event.clientY - rect.top - rect.height / 2;
const offsetX = (x / (rect.width / 2)) * maxOffset;
const offsetY = (y / (rect.height / 2)) * maxOffset;
Step 4: Reset on leave
When the pointer leaves, set both custom properties back to 0px so the button returns to its original position.
Step 5: Reduced motion
If prefers-reduced-motion is active, skip the listeners and keep the button static.
Common pitfalls
- Moving the hit area too far from the visual button can confuse users.
- Updating transform on every
pointermovewithout a transition can feel twitchy. - Forgetting
pointer-events: noneon inner text can cause the event target to flicker.
Verification checklist
- Button pulls toward the cursor on hover.
- Button returns to center on pointer leave.
- Effect is disabled under reduced motion.
- Button remains keyboard-focusable and clickable.
- The button remains fully clickable and keyboard focusable.
- Do not rely on motion alone to indicate interactivity; keep visible focus styles.
- Disable the magnetic pull when prefers-reduced-motion is active.
- Ensure the effect does not move the hit box outside the visible button area.
- Update custom properties instead of inline transforms when possible.
- Throttle pointermove handlers or use CSS :hover transitions for lighter effects.
- Use transform instead of left/top for the movement.
- Remove listeners when the element leaves the viewport or is hidden.
| API / Feature | Support | Fallback strategy |
|---|---|---|
Pointer events | All modern browsers | Not required; universally supported. |
CSS custom properties | All modern browsers | Static button with no magnetic effect. |
prefers-reduced-motion | All modern browsers | Static button. |