Skip to content
Craft

Craft · 試作

Magnetic button

Move near it and the button leans toward your cursor — its label drifting a little further for depth — then springs back the moment you leave.

  • GSAP
  • quickTo
  • Pointer

Live demo

Live · try it with mouse, touch, or keyboard

How it’s built

  • On pointermove I read the cursor's offset from the button's centre and translate the button a fraction of it (≈0.4×, clamped to ±40px) so it leans toward you without chasing the cursor off the card.
  • Both axes run through prebuilt gsap.quickTo setters — no tween is allocated per move — so it holds 60fps under a fast flick.
  • The label sits in its own layer and travels a touch further than the button, for a parallax sense of depth.
  • Leaving springs everything back to rest with elastic.out. It's a real button (keyboard-focusable, Enter/Space activates); the magnet is pointer-only, and reduced motion → it sits still.

Accessibility

  • A real, keyboard-focusable button — Enter/Space activates.
  • The magnet is pointer-only; reduced motion → it sits still.

Source

The exact component, read straight from the repo at build. Requires gsap and @gsap/react.

MagneticButtonDemo.tsx
"use client";

import { useRef } from "react";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
import { ArrowUpRight } from "lucide-react";
import { prefersReducedMotion } from "@/lib/motion";

gsap.registerPlugin(useGSAP);

const PULL = 0.4; // button follows 40% of the cursor's offset…
const LABEL_PULL = 0.22; // …and the label drifts a touch further, for depth.

/**
 * A "magnetic" button: while the pointer is over the card the button leans
 * toward the cursor (a fraction of the offset, clamped so it never leaves the
 * stage), with its label drifting a little further for a parallax sense of
 * depth. Both axes run through prebuilt `gsap.quickTo` setters — no tween is
 * allocated per move — and leaving springs everything back with `elastic.out`.
 * The magnet is pointer-only; on touch / reduced motion it's a plain button.
 */
export function MagneticButtonDemo() {
  const fieldRef = useRef<HTMLDivElement>(null);
  const btnRef = useRef<HTMLButtonElement>(null);
  const labelRef = useRef<HTMLSpanElement>(null);

  useGSAP(
    () => {
      const field = fieldRef.current;
      const btn = btnRef.current;
      const label = labelRef.current;
      if (!field || !btn || !label) return;
      // Magnetism is a mouse affordance — skip it on touch and reduced motion,
      // leaving a perfectly ordinary button.
      const fine = window.matchMedia(
        "(hover: hover) and (pointer: fine)",
      ).matches;
      if (!fine || prefersReducedMotion()) return;

      const xTo = gsap.quickTo(btn, "x", { duration: 0.4, ease: "power3.out" });
      const yTo = gsap.quickTo(btn, "y", { duration: 0.4, ease: "power3.out" });
      const lxTo = gsap.quickTo(label, "x", { duration: 0.5, ease: "power3.out" });
      const lyTo = gsap.quickTo(label, "y", { duration: 0.5, ease: "power3.out" });
      const clampBtn = gsap.utils.clamp(-40, 40);
      const clampLabel = gsap.utils.clamp(-22, 22);

      const onMove = (e: PointerEvent) => {
        const r = btn.getBoundingClientRect();
        const dx = e.clientX - (r.left + r.width / 2);
        const dy = e.clientY - (r.top + r.height / 2);
        xTo(clampBtn(dx * PULL));
        yTo(clampBtn(dy * PULL));
        lxTo(clampLabel(dx * LABEL_PULL));
        lyTo(clampLabel(dy * LABEL_PULL));
      };
      const onLeave = () => {
        gsap.to([btn, label], {
          x: 0,
          y: 0,
          duration: 0.7,
          ease: "elastic.out(1, 0.4)",
          overwrite: true,
        });
      };

      field.addEventListener("pointermove", onMove);
      field.addEventListener("pointerleave", onLeave);
      return () => {
        field.removeEventListener("pointermove", onMove);
        field.removeEventListener("pointerleave", onLeave);
      };
    },
    { scope: fieldRef },
  );

  return (
    <div
      ref={fieldRef}
      className="flex h-full w-full items-center justify-center p-10"
    >
      <button
        ref={btnRef}
        type="button"
        className="inline-flex items-center rounded-full bg-accent px-7 py-3.5 text-sm font-semibold text-accent-foreground outline-none transition-shadow duration-200 will-change-transform hover:shadow-[0_18px_45px_-15px_var(--color-accent)] focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-card"
      >
        <span
          ref={labelRef}
          className="inline-flex items-center gap-2 will-change-transform"
        >
          Say hello
          <ArrowUpRight aria-hidden className="h-4 w-4" />
        </span>
      </button>
    </div>
  );
}

Usage

usage.tsx
import { MagneticButtonDemo } from "@/components/craft/MagneticButtonDemo";

<MagneticButtonDemo />

Why these choices? The timing, easing, and reduced-motion rules behind every demo live in the motion skill file.

Read it