Rejouice — loading0

Magnetic CTA

A button that leans toward the cursor as it approaches and springs back when the cursor leaves.

Grade: ARuntimes: css · gsapStatus: live
Start a project ↗

Move the pointer near it

Magnetic CTA — move the pointer near the button

Intent

On a page with very few interactive elements, the ones that exist need to announce themselves. Magnetism makes the button feel like it wants to be pressed, which is a stronger affordance than a hover colour change on a page that has almost no colour.

Implementation

The field is padding on the wrapper, so the pull begins before the cursor reaches the visible button.

Dependencies: none

html-css-js
<span class="magnet" data-magnet>
  <a class="magnet__target" href="/contact">Start a project</a>
</span>

<style>
.magnet {
  display: inline-block;
  padding: 1.5rem; /* the magnetic field */
}

.magnet__target {
  display: inline-block;
  padding: 1rem 2rem;
  border: 1px solid currentColor;
  text-decoration: none;
  color: inherit;
  transform: translate3d(var(--mx, 0px), var(--my, 0px), 0);
  transition: transform var(--dur-instant, 120ms) var(--ease-out-quad, cubic-bezier(0.5, 1, 0.89, 1));
}

.magnet__target:focus-visible { outline: 2px solid var(--accent, #3d2fe8); outline-offset: 4px; }
</style>

<script type="module">
const fine = matchMedia("(pointer: fine) and (prefers-reduced-motion: no-preference)").matches;

if (fine) {
  const STRENGTH = 0.35;
  const MAX = 12;

  for (const field of document.querySelectorAll("[data-magnet]")) {
    const target = field.firstElementChild;

    field.addEventListener("pointermove", (event) => {
      const rect = field.getBoundingClientRect();
      const dx = event.clientX - (rect.left + rect.width / 2);
      const dy = event.clientY - (rect.top + rect.height / 2);
      const clamp = (n) => Math.max(-MAX, Math.min(MAX, n * STRENGTH));
      target.style.setProperty("--mx", clamp(dx) + "px");
      target.style.setProperty("--my", clamp(dy) + "px");
    });

    field.addEventListener("pointerleave", () => {
      target.style.setProperty("--mx", "0px");
      target.style.setProperty("--my", "0px");
    });
  }
}
</script>

Use it when

  • The one primary CTA on a page.
  • Contact links in a footer.

Avoid it when

  • Any element in a group — magnetism between adjacent targets makes both harder to hit, which is a Fitts's law regression.
  • Touch-primary contexts, where there is no cursor to attract.

Accessibility

  • The visual element moves; the hit region does not. A user who aims at where they see the button must still hit it — capping travel at 12px is what guarantees that.
  • Disable entirely under reduced motion and under `(pointer: coarse)`.
  • The focus ring must be on the element that moves, or keyboard focus appears detached.

Performance

  • Listen on the element, not the document. A document-level `mousemove` for a single button is wasteful.
  • Write `transform` from a custom property and let the compositor handle it.

Knobs

field radius
How far out the pull begins. Default: 1.6× element size
strength
Fraction of the cursor offset applied. Default: 0.35
max travel
Cap so the button never leaves its field. Default: 12px

Composition

Pairs with

Conflicts with

None.

Instruction for Claude Code

Build a magnetic CTA: an inline-block wrapper with 1.5rem padding acting as the magnetic field, containing the actual link which translates via --mx/--my custom properties. On pointermove over the wrapper, offset the target by 0.35 of the cursor's distance from the wrapper centre, clamped to ±12px; reset to zero on pointerleave. Gate the whole script behind matchMedia("(pointer: fine) and (prefers-reduced-motion: no-preference)"). Put the focus-visible ring on the element that moves. Apply this to at most one button per page — never to adjacent targets.