Microinteractions · Starter
Magnetic Button
Pointer offset from the button centre drives two springs. The label moves further than the shell for a subtle parallax inside the button.
Live preview
Move a mouse near the button, or Tab to it and press Enter.
Copy-ready · type-checkedThe preview above renders this exact file. Reduced-motion fallback included.
/**
* Magnetic Button
* A spring-driven invitation with a restrained magnetic pull.
* Requires: motion, Tailwind with shadcn semantic tokens.
*/
import { useEffect, useState } from "react";
import { motion, useReducedMotion, useSpring, useTransform } from "motion/react";
export default function MagneticButton({
reduced: reducedProp,
intensity = 1,
}: {
reduced?: boolean;
intensity?: number;
}) {
const systemReduced = useReducedMotion();
const reduced = reducedProp ?? systemReduced ?? false;
const amount = Math.min(1.5, Math.max(0.5, intensity));
const [selected, setSelected] = useState(false);
const x = useSpring(0, { stiffness: 180, damping: 20 });
const y = useSpring(0, { stiffness: 180, damping: 20 });
const labelX = useTransform(x, (value) => value * 0.25);
const labelY = useTransform(y, (value) => value * 0.25);
useEffect(() => {
if (reduced) {
x.jump(0);
y.jump(0);
}
}, [reduced, x, y]);
function reset() {
x.set(0);
y.set(0);
}
return (
<section className="flex min-h-[80vh] flex-col items-center justify-center bg-background px-6 py-20 text-foreground">
<p className="font-mono text-xs uppercase tracking-[0.2em] text-muted-foreground">
A little closer / 03
</p>
<h2 className="mt-6 text-center font-serif text-5xl tracking-tight">Good things attract.</h2>
<div
className="grid w-full max-w-md place-items-center py-16"
onPointerLeave={reset}
onPointerCancel={reset}
onPointerMove={(event) => {
if (
reduced ||
event.pointerType !== "mouse" ||
!window.matchMedia("(hover: hover) and (pointer: fine)").matches
)
return;
const rect = event.currentTarget.getBoundingClientRect();
x.set(((event.clientX - rect.left) / rect.width - 0.5) * 48 * amount);
y.set(((event.clientY - rect.top) / rect.height - 0.5) * 48 * amount);
}}
>
<motion.button
type="button"
style={{ x: reduced ? 0 : x, y: reduced ? 0 : y }}
onBlur={reset}
onClick={() => setSelected((value) => !value)}
aria-pressed={selected}
className="grid h-44 w-44 place-items-center rounded-full bg-foreground text-background focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-8"
>
<motion.span
style={{ x: reduced ? 0 : labelX, y: reduced ? 0 : labelY }}
className="px-4 text-center text-sm"
>
{selected ? "An excellent start." : "Start a conversation"}
</motion.span>
</motion.button>
</div>
<p aria-live="polite" className="text-sm text-muted-foreground">
{selected ? "Invitation accepted." : "Move closer. Or simply press Enter."}
</p>
</section>
);
}
Next patternSpotlight Card→