Home Recipes Magnetic Button Microinteractions

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

  1. Track pointer position relative to the button center on pointermove.
  2. Calculate offset as a fraction of the button size, clamped to a small range.
  3. Update CSS custom properties --mx and --my on the button.
  4. Apply transform — translate the button by the offset using CSS.
  5. Reset on leave — return the button to 0,0 with a springy transition.
  6. Reduced motion — skip position updates and keep the button static.

Source code

HTMLCSSTypeScript
<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.html
  • styles.css
  • magnetic.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 pointermove without a transition can feel twitchy.
  • Forgetting pointer-events: none on 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.
Accessibility notes
  • 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.
Performance notes
  • 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.
Browser support & fallbacks
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.