← Lab
Component18 June 2026
Magnetic button
A button that leans toward the cursor and springs back. Pointer-only, reduced-motion safe, about thirty lines.
Most magnetic buttons are broken in the same two ways: they run on touch devices where there is no cursor to be magnetic toward, and they snap back linearly, which feels like a bug rather than a spring.
This one checks for a fine pointer before binding anything, and returns with elastic.out so the release reads as physical.
"use client";
import { useRef } from "react";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
export function Magnetic({
children,
strength = 0.3,
}: {
children: React.ReactNode;
strength?: number;
}) {
const ref = useRef<HTMLSpanElement>(null);
useGSAP(() => {
const el = ref.current;
// No cursor, or motion turned down: bind nothing at all.
if (!el || !window.matchMedia("(pointer: fine)").matches) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const move = (e: MouseEvent) => {
const r = el.getBoundingClientRect();
gsap.to(el, {
x: (e.clientX - (r.left + r.width / 2)) * strength,
y: (e.clientY - (r.top + r.height / 2)) * strength,
duration: 0.5,
ease: "power3.out",
});
};
const reset = () =>
gsap.to(el, { x: 0, y: 0, duration: 0.7, ease: "elastic.out(1, 0.4)" });
el.addEventListener("mousemove", move);
el.addEventListener("mouseleave", reset);
return () => {
el.removeEventListener("mousemove", move);
el.removeEventListener("mouseleave", reset);
};
}, { scope: ref });
return <span ref={ref} className="inline-block will-change-transform">{children}</span>;
}Two things worth keeping:
pointer: finebeats a width breakpoint. A touchscreen laptop is wide and has a cursor; an iPad Pro in landscape is wide and doesn't. Asking about the input device answers the actual question.- Return the cleanup from
useGSAP. Without it, every remount leaves another pair of listeners attached, and the effect compounds until the button starts flying off.
- gsap
- react
- interaction