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
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
<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.