---
title: Magnetic Button
description: A button that leans toward the cursor and snaps back like a spring.
category: Buttons
tier: free
url: https://ui.dimaac.com/components/magnetic-button
registry: https://ui.dimaac.com/r/magnetic-button.json
---

# Magnetic Button

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.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/magnetic-button.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/magnetic-button.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/magnetic-button.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/magnetic-button`

Dependencies:

```bash
pnpm add gsap @gsap/react
npm install gsap @gsap/react
```

By hand:

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.

## Usage

```tsx
import { MagneticButton } from "@/components/dimaac/magnetic-button";

<MagneticButton children={children} />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` (required) | `ReactNode` | - | The label. Text, or text with an icon. |
| `strength` | `number` | `0.35` | How far the button leans toward the cursor. 0 = not at all, 1 = all the way to the cursor. |
| `labelStrength` | `number` | `0.5` | How much further the label leans than the button, as a share of the button's own lean. |
| `radius` | `number` | `80` | How far outside the button, in pixels, the magnet starts to pull. |
| `fill` | `string` | `"#e8532f"` | The colour of the circle that fills the button. Any CSS colour. |
| `fillTextColor` | `string` | `"#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. |
| `href` | `string` | - | Where the link goes. Only used when as="a". |
| `target` | `string` | - | Link target, e.g. "_blank". Only used when as="a". |
| `rel` | `string` | - | Link rel. A _blank link gets "noopener noreferrer" by default. |
| `type` | `"button" \| "submit" \| "reset"` | `"button"` | The button type. Only used when as="button". |
| `disabled` | `boolean` | `false` | Turns the button off. Only used when as="button". |
| `onClick` | `MouseEventHandler<HTMLElement>` | - | Runs on click or on Enter and Space. |
| `ariaLabel` | `string` | - | Accessible name, if the visible label is not enough. |
| `className` | `string` | `""` | Extra classes for the outer wrapper (layout: margins, width). |
| `buttonClassName` | `string` | `""` | Extra classes for the button itself (size, font, radius). |

## Credits

- Photo: Siren Media, Lummi (https://www.lummi.ai/photo/minimalist-stucco-building-gurez), Lummi License (free for personal and commercial use)

## Source

### magnetic-button.tsx → `components/dimaac/magnetic-button.tsx`

```tsx
"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>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { MagneticButton, type MagneticButtonProps } from "@/components/dimaac/magnetic-button";

// A calm product page. The playground controls go to the big solid button; the two small ones
// share the strength, radius and colour so you can see the same feel on every variant.
// Photo: Siren Media on Lummi (Lummi License).

const arrow = (
  <svg aria-hidden width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
    <path d="M3 8h10M9 4l4 4-4 4" />
  </svg>
);

export default function Demo(props: Partial<MagneticButtonProps>) {
  const { children, className, variant, as, href, ...shared } = props;
  return (
    <div
      className="relative flex h-full w-full flex-col overflow-hidden bg-[#f3efe9] text-[#1c1917] [--mb-focus:#1c1917] [--mb-solid-bg:#171412] [--mb-solid-fg:#f7f3ee] dark:bg-[#0f0d0c] dark:text-[#ece4dc] dark:[--mb-focus:#ece4dc] dark:[--mb-solid-bg:#ece4dc] dark:[--mb-solid-fg:#171412]"
      style={{ fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif' }}
    >
      <header className="flex items-center justify-between px-6 pt-6 text-[13px] tracking-wide uppercase opacity-70 md:px-12 md:pt-9">
        <span className="font-medium">DiMaac</span>
        <MagneticButton {...shared} variant="ghost" as="a" href="#components" radius={50} buttonClassName="!min-h-[40px] !px-5 !text-[13px]">
          Components
        </MagneticButton>
      </header>

      <main className="flex min-h-0 flex-1 flex-col justify-center gap-8 px-6 pb-8 md:grid md:grid-cols-[1.15fr_0.85fr] md:items-center md:gap-16 md:px-12 md:pb-12">
        <div className="max-w-[640px]">
          <h1 className="text-[clamp(40px,6.4vw,104px)] leading-[0.94] font-medium tracking-[-0.045em]">
            Quiet rooms,
            <br />
            made to be touched.
          </h1>
          <p className="mt-5 max-w-[38ch] text-[15px] leading-relaxed opacity-65 md:mt-7 md:text-base">Come closer. The buttons here lean in to meet you, and settle back when you go.</p>

          <div className="mt-8 flex flex-wrap items-center gap-x-10 gap-y-5 md:mt-12 md:gap-x-14">
            <MagneticButton {...shared} variant={variant ?? "solid"} as={as} href={as === "a" ? (href ?? "#project") : undefined} className={className}>
              {children ?? (
                <>
                  Start a project {arrow}
                </>
              )}
            </MagneticButton>
            <MagneticButton {...shared} variant="outline">
              View the work
            </MagneticButton>
          </div>
        </div>

        <div className="relative h-[200px] shrink-0 overflow-hidden rounded-[20px] md:h-full md:max-h-[640px] md:min-h-0">
          <img src="https://ui.dimaac.com/media/architecture/pink-stucco-2x3.jpg" alt="White and pink stucco walls with wooden windows under a deep blue sky" className="absolute inset-0 h-full w-full object-cover" draggable={false} />
        </div>
      </main>
    </div>
  );
}
```

## Agent prompt

```text
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
```
