Skip to content
ARDENT / MOTION LAB
Library

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→