Installation
Run this in a project set up with shadcn/ui:
$ pnpm dlx shadcn@latest add https://uxelith.com/r/magnetic-button.jsonIt adds these packages for you:
clsxtailwind-mergemotionThe source lands in your components folder as
magnetic-button.tsx. It's yours: change anything.
Usage
import { MagneticButton } from "@/components/magnetic-button"
export function Hero() {
return <MagneticButton size="lg">Get started</MagneticButton>
}Source
One file, 134 lines, nothing hidden. Read it before you install it.
"use client";
import { motion, useReducedMotion, useSpring, useTransform } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface MagneticButtonProps extends Omit<React.ComponentProps<"button">, "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart"> {
/** How far the button follows the pointer, from 0 (not at all) to 1 (all the way). */
strength?: number;
/** How far beyond its edge, in pixels, the button starts to feel the pointer. */
radius?: number;
variant?: "solid" | "outline" | "ghost";
size?: "sm" | "md" | "lg";
/** Render a link instead of a button, e.g. for a call to action that navigates. */
href?: string;
}
const sizes = {
sm: "h-9 px-4 text-sm",
md: "h-11 px-6 text-sm",
lg: "h-14 px-8 text-base",
} as const;
const variants = {
solid: "bg-foreground text-background shadow-[0_8px_24px_-12px_rgb(0_0_0/0.5)] hover:shadow-[0_14px_34px_-14px_rgb(0_0_0/0.6)] hover:bg-foreground/90",
outline: "border border-border bg-background/60 text-foreground backdrop-blur hover:bg-accent",
ghost: "text-foreground hover:bg-accent",
} as const;
// Motion presets (ADR-010).
const FOLLOW_SMOOTH = { mass: 0.5, stiffness: 220, damping: 26 };
// However strong the pull, the button never strays further than this (px).
const MAX_PULL = 8;
/**
* A button with a gentle magnetic field: it leans toward the pointer as you
* come near, a few pixels at most, and its label leans a little further for
* depth. It settles back without wobbling.
*
* - Only a mouse or pen moves it; touch and keyboard use it as a plain button.
* - Still under `prefers-reduced-motion`.
* - It is a real `<button>` (type="button" by default), or a real `<a>` when
* given `href`; movement is transform-only, so layout and hit testing never shift.
*/
export function MagneticButton({
strength = 0.2,
radius = 40,
variant = "solid",
size = "md",
type = "button",
href,
disabled,
className,
children,
...props
}: MagneticButtonProps) {
const ref = React.useRef<HTMLButtonElement>(null);
const reduceMotion = useReducedMotion();
const x = useSpring(0, FOLLOW_SMOOTH);
const y = useSpring(0, FOLLOW_SMOOTH);
const labelX = useTransform(x, (v) => v * 0.35);
const labelY = useTransform(y, (v) => v * 0.35);
const pull = Math.min(Math.max(strength, 0), 1);
// The field reaches past the button, so listen on the window (one listener,
// at most one read per frame) while a fine pointer is in use.
React.useEffect(() => {
const node = ref.current;
if (!node || reduceMotion || disabled || pull === 0 || !window.matchMedia("(pointer: fine)").matches) return;
let frame = 0;
let pointer = { x: 0, y: 0 };
const update = () => {
frame = 0;
const rect = node.getBoundingClientRect();
// Where the button rests, not where it has been pulled to.
const cx = rect.left + rect.width / 2 - x.get();
const cy = rect.top + rect.height / 2 - y.get();
const dx = pointer.x - cx;
const dy = pointer.y - cy;
const gap = Math.hypot(Math.max(Math.abs(dx) - rect.width / 2, 0), Math.max(Math.abs(dy) - rect.height / 2, 0));
const t = Math.max(1 - gap / Math.max(radius, 1), 0);
const force = t * t * (3 - 2 * t);
const clamp = (v: number) => Math.max(-MAX_PULL, Math.min(MAX_PULL, v));
x.set(clamp(dx * pull * force));
y.set(clamp(dy * pull * force));
};
const onMove = (event: PointerEvent) => {
if (event.pointerType === "touch") return;
pointer = { x: event.clientX, y: event.clientY };
if (!frame) frame = requestAnimationFrame(update);
};
const release = () => {
x.set(0);
y.set(0);
};
window.addEventListener("pointermove", onMove, { passive: true });
document.documentElement.addEventListener("pointerleave", release);
window.addEventListener("blur", release);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("pointermove", onMove);
document.documentElement.removeEventListener("pointerleave", release);
window.removeEventListener("blur", release);
release();
};
}, [reduceMotion, disabled, pull, radius, x, y]);
// A link when it navigates, a button when it acts.
const Root = (href ? motion.a : motion.button) as typeof motion.button;
return (
<Root
ref={ref}
{...(href ? { href } : { type, disabled })}
style={{ x, y }}
className={cn(
"relative inline-flex shrink-0 cursor-pointer select-none items-center justify-center gap-2 whitespace-nowrap rounded-full font-medium tracking-tight outline-none",
"transition-[color,background-color,box-shadow,opacity,scale] duration-200 active:scale-[0.97] motion-reduce:active:scale-100",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:pointer-events-none disabled:opacity-50",
variants[variant],
sizes[size],
className,
)}
{...props}
>
<motion.span style={{ x: labelX, y: labelY }} className="inline-flex items-center gap-2">
{children}
</motion.span>
</Root>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| strength | number | 0.2 | How far it follows the pointer, from 0 to 1. |
| radius | number | 40 | How far beyond its edge, in pixels, it feels the pointer. |
| variant | "solid" | "outline" | "ghost" | "solid" | Visual style. |
| size | "sm" | "md" | "lg" | "md" | Height and padding. |
| href | string | — | Render a real link instead of a button. |
| ...props | ButtonHTMLAttributes | — | Any button attribute. type defaults to "button". |
Accessibility
- A real <button>: focus, Enter/Space and disabled come from the browser.
- Movement is transform-only, so the hit area and layout never shift.
- Only mouse and pen move it; touch and keyboard use it as a normal button.
- Still under prefers-reduced-motion.
Changelog
- v1.1.0Latest
New: href renders the button as a link, for calls to action that navigate.