Magnetic Button
A button that leans toward the cursor and snaps back like a spring.
Customize
<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.jsonIt 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- Install the packages it uses: gsap, @gsap/react.
- Copy the component file below into components/dimaac/magnetic-button.tsx.
- It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
- Import it with import { MagneticButton } from "@/components/dimaac/magnetic-button" and give its parent a size: it fills it.
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). |
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.
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.txtFiles 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)).