Magnetic button
The button leans toward the pointer when it comes close, and its label leans a little further.
It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.
Demo.tsx
"use client";
import { useRef } from "react";
import { usePrefersReducedMotion } from "@/lib/motion";
import styles from "./demo.module.css";
export default function Demo() {
const zone = useRef<HTMLDivElement>(null);
const button = useRef<HTMLButtonElement>(null);
const reduced = usePrefersReducedMotion();
const move = (event: React.PointerEvent) => {
if (reduced || event.pointerType === "touch" || !button.current) return;
const rect = button.current.getBoundingClientRect();
const x = event.clientX - (rect.left + rect.width / 2);
const y = event.clientY - (rect.top + rect.height / 2);
button.current.style.setProperty("--x", `${x}px`);
button.current.style.setProperty("--y", `${y}px`);
};
const reset = () => {
button.current?.style.setProperty("--x", "0px");
button.current?.style.setProperty("--y", "0px");
};
return (
<div className={styles.root}>
<div ref={zone} className={styles.zone} onPointerMove={move} onPointerLeave={reset}>
<button ref={button} className={styles.button} type="button">
<span className={styles.label}>Book a demo</span>
</button>
</div>
</div>
);
}
demo.module.css
.root { width: 100%; height: 100%; display: grid; place-items: center; }
.zone { padding: 48px 64px; }
.button {
--x: 0px; --y: 0px;
min-height: 52px; padding: 0 30px; border: 0; border-radius: 999px;
background: var(--ink); color: var(--white); font-weight: 600; font-size: 16px; cursor: pointer;
transform: translate(calc(var(--x) * .35), calc(var(--y) * .35));
transition: transform .5s var(--out), background-color .18s;
}
.label { display: inline-block; transform: translate(calc(var(--x) * .2), calc(var(--y) * .2)); transition: transform .5s var(--out); }
.zone:hover .button { transition-duration: .12s, .18s; background: var(--ink-hover); }
.zone:hover .label { transition-duration: .12s; }
.button:active { scale: .97; }
Use it for
- The one primary call to action in a hero
- Playful brand sites where the button should feel physical
Avoid
- Dense UI with many buttons next to each other
- Forms, where buttons must stay still to be easy to hit
How it works
A pointermove listener on a padded zone around the button measures the offset from the button centre. The button translates by 35 percent of that offset and the label by 55 percent, both through CSS variables, so the frame work is a single style write. Leaving the zone resets the variables and a transition eases it home.
Accessibility
Reduced motion turns the pull off. Keyboard focus is unaffected and the hit area never moves away from the pointer, because the button only travels a fraction of the distance.