← Components

Magnetic Button

A button that leans toward the cursor and snaps back like a spring.

Buttons · FreeInstall ↓
Open preview ↗
Loading the demoNew tab ↗

Customize

0.35
0.50
80 px
#e8532f
Your code
<MagneticButton children={children} />

Changes show live and stay in the address, so a link keeps them.

Code

"use client";

import { useRef, type CSSProperties, type MouseEventHandler, type ReactNode } from "react";
import { gsap } from "gsap";
import { useGSAP } from "@gsap/react";

// A button that leans toward the cursor and snaps back with an elastic ease. The label leans a
// little further than the button (a parallax), and a circle of colour grows from the exact spot
// the pointer came in. Keyboards get no magnet (just a centred fill and a focus ring) and touch
// screens get the fill on press only. It is a real <button> or <a>, so it behaves like one.

export type MagneticButtonProps = {
  /** The label. Text, or text with an icon. */
  children: ReactNode;
  /** How far the button leans toward the cursor. 0 = not at all, 1 = all the way to the cursor. */
  strength?: number;
  /** How much further the label leans than the button, as a share of the button's own lean. */
  labelStrength?: number;
  /** How far outside the button, in pixels, the magnet starts to pull. */
  radius?: number;
  /** The colour of the circle that fills the button. Any CSS colour. */
  fill?: string;
  /** The label colour while the fill is showing. Any CSS colour. */
  fillTextColor?: string;
  /** "solid" is a filled pill, "outline" a ring, "ghost" a soft tint with no ring. */
  variant?: "solid" | "outline" | "ghost";
  /** Render a <button> or a link. A link needs an href. */
  as?: "button" | "a";
  /** Where the link goes. Only used when as="a". */
  href?: string;
  /** Link target, e.g. "_blank". Only used when as="a". */
  target?: string;
  /** Link rel. A _blank link gets "noopener noreferrer" by default. */
  rel?: string;
  /** The button type. Only used when as="button". */
  type?: "button" | "submit" | "reset";
  /** Turns the button off. Only used when as="button". */
  disabled?: boolean;
  /** Runs on click or on Enter and Space. */
  onClick?: MouseEventHandler<HTMLElement>;
  /** Accessible name, if the visible label is not enough. */
  ariaLabel?: string;
  /** Extra classes for the outer wrapper (layout: margins, width). */
  className?: string;
  /** Extra classes for the button itself (size, font, radius). */
  buttonClassName?: string;
};

// Colours come from CSS variables so a page can theme the button without touching the code:
//   --mb-solid-bg / --mb-solid-fg for the solid variant (default: dark pill, light text)
const variantClasses = {
  solid: "bg-[var(--mb-solid-bg,#171412)] text-[var(--mb-solid-fg,#f7f3ee)]",
  outline: "bg-transparent",
  ghost: "bg-[color-mix(in_srgb,currentColor_8%,transparent)]",
} as const;

export function MagneticButton({
  children,
  strength = 0.35,
  labelStrength = 0.5,
  radius = 80,
  fill = "#e8532f",
  fillTextColor = "#ffffff",
  variant = "solid",
  as = "button",
  href,
  target,
  rel,
  type = "button",
  disabled = false,
  onClick,
  ariaLabel,
  className = "",
  buttonClassName = "",
}: MagneticButtonProps) {
  const wrapperRef = useRef<HTMLSpanElement>(null);
  const buttonRef = useRef<HTMLElement | null>(null);
  const labelRef = useRef<HTMLSpanElement>(null);
  const fillRef = useRef<HTMLSpanElement>(null);

  // Read on every pointer move, so changing them never rebuilds the effect
  const settings = useRef({ strength, labelStrength, radius });
  settings.current = { strength, labelStrength, radius };

  useGSAP(
    (_context, contextSafe) => {
      const wrapper = wrapperRef.current;
      const button = buttonRef.current;
      const label = labelRef.current;
      const circle = fillRef.current;
      if (!wrapper || !button || !label || !circle || !contextSafe) return;

      const reducedQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
      // The magnet is for a mouse or a trackpad. Touch screens have no hover to follow.
      const hoverQuery = window.matchMedia("(hover: hover) and (pointer: fine)");

      gsap.set(circle, { xPercent: -50, yPercent: -50, scale: 0 });

      // ---- The fill circle ----
      let filled = false;
      const grow = (x: number, y: number) => {
        if (filled || disabled) return;
        filled = true;
        // The label colour change is plain CSS (see data-filled in the class list)
        button.dataset.filled = "true";
        const { width, height } = button.getBoundingClientRect();
        // Big enough to cover the farthest corner, wherever the pointer came in
        const size = Math.hypot(Math.max(x, width - x), Math.max(y, height - y)) * 2;
        gsap.set(circle, { width: size, height: size, x, y });
        if (reducedQuery.matches) {
          gsap.to(circle, { scale: 1, opacity: 1, duration: 0.15, overwrite: true });
          return;
        }
        gsap.to(circle, { scale: 1, duration: 0.6, ease: "power3.out", overwrite: true });
      };
      const shrink = (x: number, y: number) => {
        if (!filled) return;
        filled = false;
        delete button.dataset.filled;
        if (reducedQuery.matches) {
          gsap.to(circle, { scale: 0, duration: 0.15, overwrite: true });
        } else {
          // The circle slides out to where the pointer left, then closes there
          gsap.to(circle, { x, y, scale: 0, duration: 0.45, ease: "power3.inOut", overwrite: true });
        }
      };

      const local = (event: PointerEvent) => {
        const rect = button.getBoundingClientRect();
        return { x: event.clientX - rect.left, y: event.clientY - rect.top };
      };

      const onEnter = contextSafe((event: PointerEvent) => {
        if (event.pointerType === "touch") return;
        const p = local(event);
        grow(p.x, p.y);
      });
      const onLeave = contextSafe((event: PointerEvent) => {
        if (event.pointerType === "touch") return;
        const p = local(event);
        shrink(p.x, p.y);
      });

      // Touch: no hover, so the fill grows while the finger is down
      const onDown = contextSafe((event: PointerEvent) => {
        if (event.pointerType === "touch") {
          const p = local(event);
          grow(p.x, p.y);
        }
        if (!reducedQuery.matches && !disabled) gsap.to(button, { scale: 0.96, duration: 0.2, ease: "power2.out", overwrite: "auto" });
      });
      const onUp = contextSafe((event: PointerEvent) => {
        if (event.pointerType === "touch") {
          const p = local(event);
          shrink(p.x, p.y);
        }
        gsap.to(button, { scale: 1, duration: reducedQuery.matches ? 0 : 0.6, ease: "elastic.out(1, 0.4)", overwrite: "auto" });
      });

      // Keyboard: a focus ring (CSS) plus the fill from the middle
      const onFocus = contextSafe(() => {
        if (button.matches(":focus-visible")) grow(button.clientWidth / 2, button.clientHeight / 2);
      });
      const onBlur = contextSafe(() => shrink(button.clientWidth / 2, button.clientHeight / 2));

      // ---- The magnet ----
      let attracted = false;
      const limitX = gsap.utils.clamp(-20, 20);
      const limitY = gsap.utils.clamp(-9, 9);

      const release = () => {
        if (!attracted) return;
        attracted = false;
        const reduced = reducedQuery.matches;
        const options = reduced ? { duration: 0 } : { duration: 1.2, ease: "elastic.out(1, 0.3)" };
        gsap.to(button, { x: 0, y: 0, overwrite: "auto", ...options });
        gsap.to(label, { x: 0, y: 0, overwrite: "auto", ...options });
        // Stay above neighbours until the spring has mostly settled
        gsap.set(wrapper, { zIndex: 0, delay: reduced ? 0 : 0.7, overwrite: true });
      };

      const onMove = contextSafe((event: PointerEvent) => {
        if (event.pointerType === "touch" || !hoverQuery.matches || reducedQuery.matches || disabled) return;
        // The wrapper never moves, so measuring it gives a steady centre to pull toward
        const rect = wrapper.getBoundingClientRect();
        const dx = event.clientX - (rect.left + rect.width / 2);
        const dy = event.clientY - (rect.top + rect.height / 2);
        // Distance from the pointer to the button's edge (0 when the pointer is over it)
        const outsideX = Math.max(Math.abs(dx) - rect.width / 2, 0);
        const outsideY = Math.max(Math.abs(dy) - rect.height / 2, 0);
        const { strength: pull, labelStrength: extra, radius: reach } = settings.current;
        if (Math.hypot(outsideX, outsideY) > reach) {
          release();
          return;
        }
        if (!attracted) gsap.set(wrapper, { zIndex: 1, overwrite: true });
        attracted = true;
        gsap.to(button, { x: dx * pull, y: dy * pull, duration: 0.45, ease: "power3.out", overwrite: "auto" });
        // The label's own lean is on top of the button's, so it travels further (but stays inside the pill)
        gsap.to(label, { x: limitX(dx * pull * extra), y: limitY(dy * pull * extra), duration: 0.45, ease: "power3.out", overwrite: "auto" });
      });
      const onWindowLeave = contextSafe((event: MouseEvent) => {
        if (!event.relatedTarget) release();
      });

      button.addEventListener("pointerenter", onEnter);
      button.addEventListener("pointerleave", onLeave);
      button.addEventListener("pointerdown", onDown);
      button.addEventListener("pointerup", onUp);
      button.addEventListener("pointercancel", onUp);
      button.addEventListener("focus", onFocus);
      button.addEventListener("blur", onBlur);
      window.addEventListener("pointermove", onMove, { passive: true });
      document.documentElement.addEventListener("mouseleave", onWindowLeave);

      return () => {
        button.removeEventListener("pointerenter", onEnter);
        button.removeEventListener("pointerleave", onLeave);
        button.removeEventListener("pointerdown", onDown);
        button.removeEventListener("pointerup", onUp);
        button.removeEventListener("pointercancel", onUp);
        button.removeEventListener("focus", onFocus);
        button.removeEventListener("blur", onBlur);
        window.removeEventListener("pointermove", onMove);
        document.documentElement.removeEventListener("mouseleave", onWindowLeave);
        delete button.dataset.filled;
      };
    },
    { scope: wrapperRef, dependencies: [as, disabled], revertOnUpdate: true },
  );

  const safeRel = rel ?? (target === "_blank" ? "noopener noreferrer" : undefined);
  const shared = {
    ref: (node: HTMLElement | null) => {
      buttonRef.current = node;
    },
    onClick,
    "aria-label": ariaLabel,
    className: `relative isolate inline-flex min-h-[52px] cursor-pointer items-center justify-center overflow-hidden rounded-full px-8 py-3.5 text-[15px] leading-none font-medium tracking-[-0.01em] whitespace-nowrap no-underline select-none outline-offset-4 transition-colors duration-300 data-[filled=true]:text-[var(--mb-fill-text)] focus-visible:outline-2 focus-visible:outline-[var(--mb-focus,currentColor)] ${variantClasses[variant]} ${disabled ? "cursor-not-allowed opacity-50" : ""} ${buttonClassName}`,
    style: { "--mb-fill-text": fillTextColor, touchAction: "manipulation", WebkitTapHighlightColor: "transparent", willChange: "transform" } as CSSProperties,
  };

  const content = (
    <>
      {/* The ring (outline variant). It sits under the fill so the fill covers it. */}
      {variant === "outline" && <span aria-hidden className="pointer-events-none absolute inset-0 -z-20 rounded-full border-[1.5px] border-current opacity-80" />}
      {/* The fill circle: decorative, so hidden from screen readers */}
      <span ref={fillRef} aria-hidden className="pointer-events-none absolute top-0 left-0 -z-10 rounded-full" style={{ background: fill, width: 0, height: 0, transform: "scale(0)" }} />
      <span ref={labelRef} className="relative inline-flex items-center gap-2" style={{ willChange: "transform" }}>
        {children}
      </span>
    </>
  );

  return (
    <span ref={wrapperRef} className={`relative inline-flex ${className}`}>
      {as === "a" ? (
        <a {...shared} href={disabled ? undefined : href} target={target} rel={safeRel} aria-disabled={disabled || undefined}>
          {content}
        </a>
      ) : (
        <button {...shared} type={type} disabled={disabled}>
          {content}
        </button>
      )}
    </span>
  );
}

The component. Installs to components/dimaac/magnetic-button.tsx.

Install

With the shadcn CLI

npx shadcn@latest add https://ui.dimaac.com/r/magnetic-button.json

It adds components/dimaac/magnetic-button.tsx and installs what it imports. With the @dimaac namespace in components.json (Docs) it's just npx shadcn@latest add @dimaac/magnetic-button.

By hand

npm install gsap @gsap/react
  1. Install the packages it uses: gsap, @gsap/react.
  2. Copy the component file below into components/dimaac/magnetic-button.tsx.
  3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
  4. Import it with import { MagneticButton } from "@/components/dimaac/magnetic-button" and give its parent a size: it fills it.

Props

PropTypeDefaultDescription
children (required)ReactNode-The label. Text, or text with an icon.
strengthnumber0.35How far the button leans toward the cursor. 0 = not at all, 1 = all the way to the cursor.
labelStrengthnumber0.5How much further the label leans than the button, as a share of the button's own lean.
radiusnumber80How far outside the button, in pixels, the magnet starts to pull.
fillstring"#e8532f"The colour of the circle that fills the button. Any CSS colour.
fillTextColorstring"#ffffff"The label colour while the fill is showing. Any CSS colour.
variant"solid" | "outline" | "ghost""solid""solid" is a filled pill, "outline" a ring, "ghost" a soft tint with no ring.
as"button" | "a""button"Render a <button> or a link. A link needs an href.
hrefstring-Where the link goes. Only used when as="a".
targetstring-Link target, e.g. "_blank". Only used when as="a".
relstring-Link rel. A _blank link gets "noopener noreferrer" by default.
type"button" | "submit" | "reset""button"The button type. Only used when as="button".
disabledbooleanfalseTurns the button off. Only used when as="button".
onClickMouseEventHandler<HTMLElement>-Runs on click or on Enter and Space.
ariaLabelstring-Accessible name, if the visible label is not enough.
classNamestring""Extra classes for the outer wrapper (layout: margins, width).
buttonClassNamestring""Extra classes for the button itself (size, font, radius).

Use with AI

Agent prompt

Paste it into Claude Code, Cursor or any coding agent, or open it straight in a chat. It carries your Customize settings.

Prompt
Add the Magnetic Button component (<MagneticButton />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/magnetic-button
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/magnetic-button.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/magnetic-button.json

Then use it like this:
import { MagneticButton } from "@/components/dimaac/magnetic-button";
<MagneticButton children={children} />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/magnetic-button.tsx and install gsap, @gsap/react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt

Files agents read

llms.txt
Every component and how to install it
magnetic-button.md
This page as Markdown: install, props and the full source
magnetic-button.json
The shadcn registry item

About

A real button (or link) that leans toward the pointer as it comes near, then snaps back with an elastic ease. The label leans a little further than the button, which gives it depth, and a circle of colour grows from the exact spot the pointer came in and closes where it left. Keyboards get a focus ring and a centred fill instead of the magnet, touch screens get the fill while a finger is down, and visitors who prefer reduced motion get a plain colour change. Three variants (solid, outline, ghost), renders as a <button> or an <a>, and the colours follow CSS variables so it fits light and dark themes.

Installs to
components/dimaac/magnetic-button.tsx
Uses
gsap, @gsap/react
Tags
gsap, button, magnetic, hover, cta

Photo by Siren Media on Lummi (Lummi License (free for personal and commercial use)).