Skip to content

Magnetic Button

A button that is pulled toward the pointer across a field around it: the whole body drifts, the label trails on a softer spring, a specular light and edge glint follow the cursor, it squashes on press and springs back. No dependencies.

DOM + CSS0 deps1 file · 16 KB TSXreduced-motion aware

Freeplayfulminimal
Preview

Props

24
140
Rounded
No runtime dependencies
"use client";

import { useEffect, useRef, useState, type ComponentPropsWithoutRef } from "react";

export type MagneticButtonVariant = "red" | "outline" | "dark" | "glass";

export type MagneticButtonProps = Omit<ComponentPropsWithoutRef<"button">, "children"> & {
  /** Button text. */
  label?: string;
  /** Maximum pull in px: how far the whole button may drift toward the pointer. */
  strength?: number;
  /** Pill shape (true) or soft square (false). */
  rounded?: boolean;
  /** "red" is a refined vermilion accent. */
  variant?: MagneticButtonVariant;
  /** Reach of the magnetic field in px, measured from the button's edge. */
  radius?: number;
  /** When set, a click swaps the label for this text (with a check) for confirmMs. */
  confirmLabel?: string;
  confirmMs?: number;
  /** Goes on the outer wrapper (layout, width, size variables). */
  className?: string;
};

type Tone = {
  fill: string;
  edge: string;
  text: string;
  glow: string;
  spot: number;
  rgb: string;
  blur: boolean;
};

const TONES: Record<MagneticButtonVariant, Tone> = {
  red: {
    fill: "linear-gradient(180deg, #ff6a45 0%, #f2432b 100%)",
    edge: "inset 0 0 0 1px rgba(255,255,255,0.2), inset 0 1px 0 rgba(255,255,255,0.4), inset 0 -14px 20px -10px rgba(120,10,0,0.5)",
    text: "#ffffff",
    glow: "0 16px 36px 6px rgba(255,84,56,0.5), 0 8px 18px 8px rgba(0,0,0,0.22)",
    spot: 0.4,
    rgb: "255,255,255",
    blur: false,
  },
  dark: {
    fill: "#171717",
    edge: "inset 0 0 0 1px rgba(255,255,255,0.09), inset 0 1px 0 rgba(255,255,255,0.12)",
    text: "#ffffff",
    glow: "0 20px 40px 0px rgba(0,0,0,0.85), 0 6px 14px 8px rgba(0,0,0,0.35)",
    spot: 0.12,
    rgb: "255,255,255",
    blur: false,
  },
  outline: {
    fill: "transparent",
    edge: "inset 0 0 0 1px #171717",
    text: "#171717",
    glow: "none",
    spot: 0.09,
    rgb: "23,23,23",
    blur: false,
  },
  glass: {
    fill: "rgba(255,255,255,0.075)",
    edge: "inset 0 0 0 1px rgba(255,255,255,0.16), inset 0 1px 0 rgba(255,255,255,0.28)",
    text: "rgba(255,255,255,0.94)",
    glow: "0 20px 42px 0px rgba(140,170,255,0.34)",
    spot: 0.2,
    rgb: "255,255,255",
    blur: true,
  },
};

const CSS = [
  ".mgb-a,.mgb-b{transition:transform 440ms cubic-bezier(0.2,0.8,0.2,1),opacity 300ms ease,filter 300ms ease}",
  '.mgb-a[data-off="true"]{transform:translateY(-70%);opacity:0;filter:blur(4px)}',
  '.mgb-b[data-off="true"]{transform:translateY(70%);opacity:0;filter:blur(4px)}',
  ".mgb-check{stroke-dasharray:20;stroke-dashoffset:20}",
  '.mgb-b[data-off="false"] .mgb-check{animation:mgb-draw 420ms 90ms cubic-bezier(0.2,0.8,0.2,1) forwards}',
  ".mgb-pulse{position:absolute;width:280px;height:280px;border-radius:9999px;pointer-events:none;animation:mgb-ring 700ms cubic-bezier(0.2,0.7,0.2,1) forwards}",
  ".mgb-ring{opacity:0;transition:opacity 120ms ease}",
  ".mgb-btn:focus-visible>.mgb-ring{opacity:1}",
  "@keyframes mgb-draw{to{stroke-dashoffset:0}}",
  "@keyframes mgb-ring{from{transform:translate(-50%,-50%) scale(0.05);opacity:0.55}to{transform:translate(-50%,-50%) scale(1);opacity:0}}",
  "@media (prefers-reduced-motion: reduce){.mgb-a,.mgb-b{transition:opacity 160ms ease}.mgb-a[data-off=\"true\"],.mgb-b[data-off=\"true\"]{transform:none;filter:none}.mgb-check{stroke-dashoffset:0;animation:none!important}.mgb-pulse{display:none}}",
].join("\n");

type Spring = { x: number; v: number; t: number; k: number; c: number };
const spring = (x: number, k: number, c: number): Spring => ({ x, v: 0, t: x, k, c });
const smooth = (a: number, b: number, x: number) => {
  const t = Math.min(1, Math.max(0, (x - a) / (b - a)));
  return t * t * (3 - 2 * t);
};
const nowMs = () => performance.now();

export function MagneticButton({
  label = "Copy this",
  strength = 24,
  rounded = true,
  variant = "red",
  radius = 140,
  confirmLabel,
  confirmMs = 1600,
  className,
  disabled,
  onClick,
  type = "button",
  style,
  ...rest
}: MagneticButtonProps) {
  const outerRef = useRef<HTMLSpanElement>(null);
  const btnRef = useRef<HTMLButtonElement>(null);
  const labelRef = useRef<HTMLSpanElement>(null);
  const glowRef = useRef<HTMLSpanElement>(null);
  const live = useRef({ strength, radius, disabled: !!disabled });
  const pulsePos = useRef({ x: 0, y: 0 });
  const timer = useRef(0);
  const [done, setDone] = useState(false);
  const [pulse, setPulse] = useState<{ k: number; x: number; y: number } | null>(null);

  useEffect(() => {
    live.current = { strength, radius, disabled: !!disabled };
  });

  useEffect(() => () => window.clearTimeout(timer.current), []);

  useEffect(() => {
    const outer = outerRef.current;
    const btn = btnRef.current;
    const lab = labelRef.current;
    const glow = glowRef.current;
    if (!outer || !btn || !lab || !glow) return;

    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    let reduce = mq.matches;
    let hovered = false;
    let pressed = false;
    let focused = false;
    let near = 0;
    let mx = 0;
    let my = 0;
    let tmx = 0;
    let tmy = 0;
    let raf = 0;
    let last = -1;

    const bx = spring(0, 190, 15);
    const by = spring(0, 190, 15);
    const lx = spring(0, 120, 11);
    const ly = spring(0, 120, 11);
    const sx = spring(1, 260, 17);
    const sy = spring(1, 260, 17);
    const lit = spring(0, 170, 26);
    const all = [bx, by, lx, ly, sx, sy, lit];

    const retarget = () => {
      const off = live.current.disabled;
      sx.t = reduce || off ? 1 : pressed ? 0.965 : hovered ? 1.04 : 1;
      sy.t = reduce || off ? 1 : pressed ? 0.925 : hovered ? 1.04 : 1;
      lit.t = off ? 0 : pressed || hovered ? 1 : focused ? 0.55 : near * 0.4;
    };

    const write = () => {
      btn.style.transform =
        "translate3d(" + bx.x.toFixed(2) + "px," + by.x.toFixed(2) + "px,0) scale(" + sx.x.toFixed(4) + "," + sy.x.toFixed(4) + ")";
      lab.style.transform = "translate3d(" + lx.x.toFixed(2) + "px," + ly.x.toFixed(2) + "px,0)";
      const l = Math.min(1, Math.max(0, lit.x));
      glow.style.transform = "translate3d(" + (-bx.x * 0.35).toFixed(2) + "px," + (-by.x * 0.35 + l * 5).toFixed(2) + "px,0)";
      glow.style.opacity = (0.65 + 0.35 * l).toFixed(3);
      btn.style.setProperty("--mx", mx.toFixed(1) + "px");
      btn.style.setProperty("--my", my.toFixed(1) + "px");
      btn.style.setProperty("--lit", l.toFixed(3));
    };

    const frame = (now: number) => {
      raf = 0;
      const dt = last < 0 ? 1 / 60 : Math.min((now - last) / 1000, 1 / 20);
      last = now;
      const steps = Math.max(1, Math.ceil(dt * 120));
      const h = dt / steps;
      for (let i = 0; i < steps; i++) {
        for (const s of all) {
          s.v += (s.k * (s.t - s.x) - s.c * s.v) * h;
          s.x += s.v * h;
        }
      }
      const f = 1 - Math.exp(-dt * 18);
      mx += (tmx - mx) * f;
      my += (tmy - my) * f;
      let moving = Math.abs(tmx - mx) > 0.4 || Math.abs(tmy - my) > 0.4;
      for (const s of all) if (Math.abs(s.t - s.x) > 0.01 || Math.abs(s.v) > 0.05) moving = true;
      if (!moving) {
        for (const s of all) {
          s.x = s.t;
          s.v = 0;
        }
        mx = tmx;
        my = tmy;
      }
      write();
      if (moving) raf = requestAnimationFrame(frame);
      else last = -1;
    };
    const kick = () => {
      if (!raf) {
        if (last < 0) last = nowMs();
        raf = requestAnimationFrame(frame);
      }
    };

    const release = () => {
      hovered = false;
      near = 0;
      bx.t = by.t = lx.t = ly.t = 0;
      retarget();
      kick();
    };

    const onMove = (e: PointerEvent) => {
      if (e.pointerType === "touch") return;
      if (live.current.disabled) {
        if (hovered || near) release();
        return;
      }
      const r = outer.getBoundingClientRect();
      const w = outer.offsetWidth;
      const hh = outer.offsetHeight;
      if (!r.width || !r.height) return;
      const s = w / r.width;
      const px = (e.clientX - r.left) * s;
      const py = (e.clientY - r.top) * s;
      const dx = px - w / 2;
      const dy = py - hh / 2;
      const ex = Math.max(Math.abs(dx) - w / 2, 0);
      const ey = Math.max(Math.abs(dy) - hh / 2, 0);
      const dist = Math.hypot(ex, ey);
      const reach = Math.max(8, live.current.radius);
      const t = 1 - smooth(0, reach, dist);
      const inside = dist === 0;
      if (t <= 0 && !hovered && near === 0) return;
      if (inside && !hovered) {
        mx = tmx = px;
        my = tmy = py;
      }
      hovered = inside;
      near = t;
      if (t > 0) {
        tmx = px;
        tmy = py;
      }
      const len = Math.hypot(dx, dy) || 1;
      const mag = reduce ? 0 : live.current.strength * Math.tanh(len / (w * 0.6)) * t;
      bx.t = (dx / len) * mag;
      by.t = (dy / len) * mag;
      lx.t = bx.t * 0.55;
      ly.t = by.t * 0.55;
      retarget();
      kick();
    };

    const onDown = (e: PointerEvent) => {
      if (live.current.disabled) return;
      const r = outer.getBoundingClientRect();
      const s = r.width ? outer.offsetWidth / r.width : 1;
      const px = (e.clientX - r.left) * s;
      const py = (e.clientY - r.top) * s;
      pulsePos.current = { x: px, y: py };
      if (e.pointerType === "touch" || !hovered) {
        mx = tmx = px;
        my = tmy = py;
      }
      pressed = true;
      retarget();
      kick();
    };
    const onUp = () => {
      if (!pressed) return;
      pressed = false;
      if (!hovered) near = 0;
      retarget();
      kick();
    };
    const onKeyDown = (e: KeyboardEvent) => {
      if ((e.key !== "Enter" && e.key !== " ") || e.repeat || live.current.disabled) return;
      mx = tmx = outer.offsetWidth / 2;
      my = tmy = outer.offsetHeight / 2;
      pulsePos.current = { x: tmx, y: tmy };
      pressed = true;
      retarget();
      kick();
    };
    const onFocus = () => {
      if (!btn.matches(":focus-visible")) return;
      focused = true;
      mx = tmx = outer.offsetWidth / 2;
      my = tmy = outer.offsetHeight / 2;
      retarget();
      kick();
    };
    const onBlur = () => {
      focused = false;
      pressed = false;
      retarget();
      kick();
    };
    const onOut = (e: PointerEvent) => {
      if (!e.relatedTarget) release();
    };
    const onMq = () => {
      reduce = mq.matches;
      if (reduce) release();
      retarget();
      kick();
    };

    window.addEventListener("pointermove", onMove, { passive: true });
    window.addEventListener("pointerup", onUp);
    window.addEventListener("pointercancel", onUp);
    window.addEventListener("blur", release);
    document.addEventListener("pointerout", onOut);
    btn.addEventListener("pointerdown", onDown);
    btn.addEventListener("keydown", onKeyDown);
    btn.addEventListener("keyup", onUp);
    btn.addEventListener("focus", onFocus);
    btn.addEventListener("blur", onBlur);
    mq.addEventListener("change", onMq);
    write();

    return () => {
      if (raf) cancelAnimationFrame(raf);
      window.removeEventListener("pointermove", onMove);
      window.removeEventListener("pointerup", onUp);
      window.removeEventListener("pointercancel", onUp);
      window.removeEventListener("blur", release);
      document.removeEventListener("pointerout", onOut);
      btn.removeEventListener("pointerdown", onDown);
      btn.removeEventListener("keydown", onKeyDown);
      btn.removeEventListener("keyup", onUp);
      btn.removeEventListener("focus", onFocus);
      btn.removeEventListener("blur", onBlur);
      mq.removeEventListener("change", onMq);
    };
  }, []);

  const tone = TONES[variant] ?? TONES.red;
  const br = rounded ? 9999 : 0;
  const confirmOn = !!confirmLabel && done;

  return (
    <span ref={outerRef} className={"relative inline-flex align-middle " + (className ?? "")} style={{ touchAction: "manipulation" }}>
      <style>{CSS}</style>
      <span
        ref={glowRef}
        aria-hidden
        className="pointer-events-none absolute inset-3.5"
        style={{ borderRadius: br, boxShadow: tone.glow, opacity: 0.65 }}
      />
      <button
        ref={btnRef}
        type={type}
        disabled={disabled}
        onClick={(e) => {
          onClick?.(e);
          if (e.defaultPrevented) return;
          setPulse((p) => ({ k: (p?.k ?? 0) + 1, x: pulsePos.current.x, y: pulsePos.current.y }));
          if (confirmLabel) {
            setDone(true);
            window.clearTimeout(timer.current);
            timer.current = window.setTimeout(() => setDone(false), confirmMs);
          }
        }}
        {...rest}
        className="mgb-btn relative flex h-[var(--mgb-h,56px)] min-w-0 flex-1 cursor-pointer select-none items-center justify-center whitespace-nowrap px-[var(--mgb-px,32px)] text-[length:var(--mgb-fs,17px)] font-semibold tracking-[-0.015em] will-change-transform disabled:cursor-not-allowed disabled:opacity-45"
        style={{
          ...style,
          borderRadius: br,
          background: tone.fill,
          color: tone.text,
          boxShadow: tone.edge,
          WebkitTapHighlightColor: "transparent",
          outline: "none",
          backdropFilter: tone.blur ? "blur(14px) saturate(1.5)" : undefined,
          WebkitBackdropFilter: tone.blur ? "blur(14px) saturate(1.5)" : undefined,
        }}
      >
        <span aria-hidden className="pointer-events-none absolute inset-0 overflow-hidden" style={{ borderRadius: br }}>
          <span
            className="absolute inset-0"
            style={{
              background:
                "radial-gradient(circle 130px at var(--mx, 50%) var(--my, 50%), rgba(" + tone.rgb + "," + tone.spot + "), transparent 72%)",
              opacity: "var(--lit, 0)",
            }}
          />
          {pulse ? (
            <span
              key={pulse.k}
              className="mgb-pulse"
              style={{
                left: pulse.x,
                top: pulse.y,
                background: "radial-gradient(circle, rgba(" + tone.rgb + ",0) 52%, rgba(" + tone.rgb + ",0.4) 70%, rgba(" + tone.rgb + ",0) 72%)",
              }}
            />
          ) : null}
        </span>
        <span
          aria-hidden
          className="pointer-events-none absolute inset-0"
          style={{
            borderRadius: br,
            padding: 1,
            background: "radial-gradient(circle 110px at var(--mx, 50%) var(--my, 50%), rgba(" + tone.rgb + ",0.95), rgba(" + tone.rgb + ",0) 70%)",
            opacity: "var(--lit, 0)",
            mask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
            WebkitMask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
            maskComposite: "exclude",
            WebkitMaskComposite: "xor",
          }}
        />
        <span
          aria-hidden
          className="mgb-ring pointer-events-none absolute -inset-[3px]"
          style={{ borderRadius: rounded ? 9999 : 3, boxShadow: "0 0 0 2px #ffffff, 0 0 0 4px #171717" }}
        />
        <span ref={labelRef} className="relative z-10 grid place-items-center will-change-transform">
          <span className="mgb-a col-start-1 row-start-1" data-off={confirmOn ? "true" : "false"} aria-hidden={confirmOn}>
            {label}
          </span>
          {confirmLabel ? (
            <span className="mgb-b col-start-1 row-start-1 inline-flex items-center gap-[0.45em]" data-off={confirmOn ? "false" : "true"} aria-hidden={!confirmOn}>
              <svg width="1em" height="1em" viewBox="0 0 16 16" fill="none" aria-hidden>
                <path className="mgb-check" d="M3 8.6 6.4 12 13 4.6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
              </svg>
              {confirmLabel}
            </span>
          ) : null}
        </span>
      </button>
      <span className="sr-only" role="status" aria-live="polite">
        {confirmOn ? confirmLabel : ""}
      </span>
    </span>
  );
}

Free. Pro unlocks everything, from $27/month.

Details

About this component

The pull starts before the pointer arrives. A listener on the window measures the distance from the cursor to the button's edge, and inside a field around it (140px by default) the whole button drifts toward the cursor, its offset saturating smoothly so it never runs away, fading to nothing at the field's edge. The label rides a second, softer spring on top of the body, so it trails and then catches up, which is the parallax that sells the effect. Under the cursor a specular spot and a 1px edge glint follow the pointer across the surface. Pressing squashes the body (wider, flatter) and a ring of light expands from the touch point; letting go springs back past rest and settles. All of it is hand-written spring integration in a single requestAnimationFrame loop that sleeps whenever everything is at rest and writes straight to the DOM, so moving the mouse never re-renders React. Four variants: a refined vermilion, a neutral-900 dark, an ink outline and a frosted glass. Set confirmLabel and a click swaps the label for a check and a word, then swaps back. Red, dark and outline read on light and dark pages; glass wants a dark one.

Curator’s note

Magnetic Button, rebuilt in October. The first version nudged a label with a framer-motion spring; this one pulls the whole button across a field, lights it under the cursor, and needs nothing but React.

Pairs well with