Magnetic Button

A button that leans toward your cursor on a spring, with the label drifting further than the shell.

  • Buttons
  • Creative agency
  • React only · 0 deps
  • 1 file
Full screen

Browser support · All evergreen browsers. Attraction is pointer-only and off for touch and reduced motion.

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/magnetic-button.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/buttons/magnetic-button.tsx
1/**
2 * MagneticButton — a button that leans toward the cursor when it gets close,
3 * driven by a small spring integrator (stiffness 380, damping 30, mass 1).
4 * The label travels further than the shell, which gives a parallax feel.
5 *
6 * Props: strength (0–1, how far it follows, default .35), radius (px of
7 * attraction beyond the button edge, default 80), shape ("pill" | "circle"),
8 * size ("md" | "lg" | "xl"), tone ("solid" | "outline"), theme, plus native
9 * <button> props (or pass `href` to render an <a>).
10 * CSS vars: --fd-mag-bg, --fd-mag-fg, --fd-mag-size.
11 *
12 * Browser support: all evergreen browsers. Pointer attraction is disabled for
13 * touch input and for prefers-reduced-motion; the button stays fully usable.
14 * Accessibility: native <button>/<a>, focus ring, no transforms affect the
15 * hit target semantics. The rAF loop runs only while the button is moving and
16 * is cleaned up on unmount.
17 */
18import { useEffect, useRef, useState } from "react";
19import type { ButtonHTMLAttributes, ReactNode } from "react";
20
21export interface MagneticButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
22 children: ReactNode;
23 href?: string;
24 strength?: number;
25 radius?: number;
26 shape?: "pill" | "circle";
27 size?: "md" | "lg" | "xl";
28 tone?: "solid" | "outline";
29 theme?: "light" | "dark";
30}
31
32export function MagneticButton({
33 children,
34 href,
35 strength = 0.35,
36 radius = 80,
37 shape = "pill",
38 size = "lg",
39 tone = "solid",
40 theme,
41 className,
42 ...rest
43}: MagneticButtonProps) {
44 const wrap = useRef<HTMLSpanElement>(null);
45 const shell = useRef<HTMLSpanElement>(null);
46 const label = useRef<HTMLSpanElement>(null);
47
48 useEffect(() => {
49 const host = wrap.current;
50 if (!host || !window.matchMedia) return;
51 if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
52
53 const s = { x: 0, y: 0, vx: 0, vy: 0, tx: 0, ty: 0 };
54 let raf = 0;
55 let last = 0;
56 const K = 380;
57 const C = 30;
58
59 const apply = () => {
60 if (shell.current) shell.current.style.transform = `translate3d(${s.x.toFixed(2)}px,${s.y.toFixed(2)}px,0)`;
61 if (label.current) label.current.style.transform = `translate3d(${(s.x * 0.55).toFixed(2)}px,${(s.y * 0.55).toFixed(2)}px,0)`;
62 };
63
64 const tick = (t: number) => {
65 const dt = Math.min((t - last) / 1000 || 0.016, 0.032);
66 last = t;
67 // semi-implicit Euler, two substeps for stability
68 for (let i = 0; i < 2; i++) {
69 const h = dt / 2;
70 s.vx += (-K * (s.x - s.tx) - C * s.vx) * h;
71 s.vy += (-K * (s.y - s.ty) - C * s.vy) * h;
72 s.x += s.vx * h;
73 s.y += s.vy * h;
74 }
75 apply();
76 const rest = Math.abs(s.x - s.tx) < 0.05 && Math.abs(s.y - s.ty) < 0.05 && Math.abs(s.vx) < 0.05 && Math.abs(s.vy) < 0.05;
77 if (rest) {
78 s.x = s.tx;
79 s.y = s.ty;
80 s.vx = s.vy = 0;
81 apply();
82 raf = 0;
83 } else raf = requestAnimationFrame(tick);
84 };
85 const kick = () => {
86 if (!raf) {
87 last = performance.now();
88 raf = requestAnimationFrame(tick);
89 }
90 };
91
92 const onMove = (e: PointerEvent) => {
93 if (e.pointerType === "touch") return;
94 const r = host.getBoundingClientRect();
95 const cx = r.left + r.width / 2;
96 const cy = r.top + r.height / 2;
97 const dx = e.clientX - cx;
98 const dy = e.clientY - cy;
99 const inside = Math.abs(dx) < r.width / 2 + radius && Math.abs(dy) < r.height / 2 + radius;
100 const nx = inside ? dx * strength : 0;
101 const ny = inside ? dy * strength : 0;
102 if (nx !== s.tx || ny !== s.ty) {
103 s.tx = nx;
104 s.ty = ny;
105 kick();
106 }
107 };
108 const onLeave = () => {
109 s.tx = s.ty = 0;
110 kick();
111 };
112
113 window.addEventListener("pointermove", onMove, { passive: true });
114 document.documentElement.addEventListener("pointerleave", onLeave);
115 window.addEventListener("blur", onLeave);
116 return () => {
117 window.removeEventListener("pointermove", onMove);
118 document.documentElement.removeEventListener("pointerleave", onLeave);
119 window.removeEventListener("blur", onLeave);
120 if (raf) cancelAnimationFrame(raf);
121 };
122 }, [strength, radius]);
123
124 const inner = (
125 <>
126 <span ref={shell} className="fd-mag-shell" aria-hidden="true" />
127 <span ref={label} className="fd-mag-label">{children}</span>
128 </>
129 );
130 const cls = `fd-mag-btn${className ? " " + className : ""}`;
131
132 return (
133 <span ref={wrap} className="fd-mag" data-shape={shape} data-size={size} data-tone={tone} data-fd-theme={theme}>
134 <style>{css}</style>
135 {href ? (
136 <a className={cls} href={href}>{inner}</a>
137 ) : (
138 <button type="button" className={cls} {...rest}>{inner}</button>
139 )}
140 </span>
141 );
142}
143
144const css = `
145:where(.fd-mag,.fd-mag *){box-sizing:border-box;margin:0;padding:0;font:inherit;letter-spacing:inherit;color:inherit;text-decoration:none;list-style:none}
146:where(.fd-mag,.fd-mag *)::before,:where(.fd-mag,.fd-mag *)::after{box-sizing:border-box}
147:where(.fd-mag button){appearance:none;-webkit-appearance:none;background:none;border:0;border-radius:0}
148.fd-mag{
149 --fd-ease-out:cubic-bezier(.22,1,.36,1);--fd-spring:cubic-bezier(.34,1.56,.64,1);--fd-dur-1:120ms;--fd-dur-2:240ms;--fd-dur-3:420ms;
150 --fd-mag-bg:#0B0B0F;--fd-mag-fg:#F5F5F7;--fd-mag-ink:#0B0B0F;--fd-mag-line:rgba(11,11,15,.18);
151 display:inline-flex;vertical-align:middle;
152 font:500 15px/1 ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;
153}
154[data-fd-theme="dark"] .fd-mag,.fd-mag[data-fd-theme="dark"]{--fd-mag-bg:#F5F5F7;--fd-mag-fg:#0B0B0F;--fd-mag-ink:#F5F5F7;--fd-mag-line:rgba(255,255,255,.22)}
155.fd-mag[data-size="md"]{--m-h:40px;--m-px:20px;--m-fs:15px}
156.fd-mag[data-size="lg"]{--m-h:52px;--m-px:28px;--m-fs:17px}
157.fd-mag[data-size="xl"]{--m-h:64px;--m-px:36px;--m-fs:20px}
158.fd-mag-btn{position:relative;display:inline-grid;place-items:center;height:var(--m-h);padding:0 var(--m-px);border-radius:9999px;
159 appearance:none;-webkit-appearance:none;border:0;outline:0;cursor:pointer;user-select:none;-webkit-tap-highlight-color:transparent;
160 background:none;color:var(--fd-mag-fg);font-weight:500;font-size:var(--m-fs);line-height:1;font-family:inherit;letter-spacing:-.01em;white-space:nowrap;
161 transform:scale(1);transition:transform var(--fd-dur-3) var(--fd-spring)}
162.fd-mag[data-shape="circle"] .fd-mag-btn{width:var(--fd-mag-size,148px);height:var(--fd-mag-size,148px);padding:0}
163.fd-mag[data-tone="outline"] .fd-mag-btn{color:var(--fd-mag-ink)}
164.fd-mag-btn:active{transform:scale(.96);transition:transform var(--fd-dur-1) var(--fd-ease-out)}
165.fd-mag-btn:focus-visible{outline:2px solid rgba(10,132,255,.8);outline-offset:4px}
166.fd-mag-shell{position:absolute;inset:0;border-radius:inherit;background:var(--fd-mag-bg);will-change:transform;
167 box-shadow:0 1px 0 rgba(255,255,255,.18) inset,0 1px 2px rgba(11,11,15,.2),0 14px 28px -14px rgba(11,11,15,.5);
168 transition:box-shadow var(--fd-dur-2) var(--fd-ease-out),background-color var(--fd-dur-2) var(--fd-ease-out)}
169.fd-mag[data-tone="outline"] .fd-mag-shell{background:transparent;box-shadow:0 0 0 1px var(--fd-mag-line)}
170@media (hover:hover){
171 .fd-mag-btn:hover .fd-mag-shell{box-shadow:0 1px 0 rgba(255,255,255,.2) inset,0 2px 4px rgba(11,11,15,.2),0 22px 36px -14px rgba(11,11,15,.55)}
172 .fd-mag[data-tone="outline"] .fd-mag-btn:hover .fd-mag-shell{box-shadow:0 0 0 1px var(--fd-mag-ink)}
173}
174.fd-mag-label{position:relative;display:block;will-change:transform;pointer-events:none}
175@media (prefers-reduced-motion:reduce){.fd-mag-btn,.fd-mag-btn:active{transition:none;transform:none}.fd-mag-shell,.fd-mag-label{transform:none!important}}
176@media (prefers-contrast:more){.fd-mag-shell{box-shadow:0 0 0 1px currentColor}}
177`;
178
179/* ───────────────────────────── demo ───────────────────────────── */
180
181const arrowUR = (
182 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" style={{ display: "inline-block", marginLeft: 6 }}><path d="M7 17L17 7M8 7h9v9" /></svg>
183);
184
185export function Demo() {
186 const [theme, setTheme] = useState<"light" | "dark">("dark");
187 const [copied, setCopied] = useState(false);
188
189 return (
190 <div className="fd-mag-demo" data-fd-theme={theme}>
191 <style>{demoCss}</style>
192 <header className="fd-mag-demo-bar">
193 <span className="fd-mag-demo-brand">Kiln&nbsp;&amp;&nbsp;Field<sup>®</sup></span>
194 <nav className="fd-mag-demo-nav" aria-label="Primary">
195 <a href="#work">Work</a><a href="#studio">Studio</a><a href="#journal">Journal</a>
196 </nav>
197 <button
198 type="button"
199 className="fd-mag-demo-theme"
200 aria-label={theme === "dark" ? "Switch to light theme" : "Switch to dark theme"}
201 onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
202 >
203 {theme === "dark" ? "Light" : "Dark"}
204 </button>
205 </header>
206
207 <main className="fd-mag-demo-main">
208 <p className="fd-mag-demo-eyebrow">Brand · Motion · Digital product — Lisbon / Berlin</p>
209 <h1 className="fd-mag-demo-h1">
210 Tell us what you’re <em>making</em>.<br />We’ll make it unforgettable.
211 </h1>
212
213 <div className="fd-mag-demo-cta">
214 <MagneticButton shape="circle" size="xl" strength={0.4} radius={110} onClick={() => setCopied(true)} className="fd-mag-demo-big">
215 {copied ? "Brief sent" : "Say hello"}
216 </MagneticButton>
217 <div className="fd-mag-demo-aside">
218 <p>New projects for Q1 2027 — we take on six a year.</p>
219 <div className="fd-mag-demo-links">
220 <MagneticButton size="md" tone="outline" href="mailto:studio@kilnandfield.example">studio@kilnandfield.example</MagneticButton>
221 <MagneticButton size="md" tone="outline" href="#deck">Credentials deck{arrowUR}</MagneticButton>
222 </div>
223 </div>
224 </div>
225
226 <dl className="fd-mag-demo-facts">
227 <div><dt>Recent</dt><dd>Oda Hotels · identity &amp; booking</dd></div>
228 <div><dt>Awards</dt><dd>D&amp;AD Pencil, Awwwards SOTD ×4</dd></div>
229 <div><dt>Response time</dt><dd>Within two working days</dd></div>
230 </dl>
231 </main>
232 </div>
233 );
234}
235
236const demoCss = `
237:where(.fd-mag-demo,.fd-mag-demo *){box-sizing:border-box;margin:0;padding:0;font:inherit;letter-spacing:inherit;color:inherit;text-decoration:none;list-style:none}
238:where(.fd-mag-demo,.fd-mag-demo *)::before,:where(.fd-mag-demo,.fd-mag-demo *)::after{box-sizing:border-box}
239:where(.fd-mag-demo button){appearance:none;-webkit-appearance:none;background:none;border:0;border-radius:0}
240.fd-mag-demo{--d-font:ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;--d-disp:ui-sans-serif,-apple-system,"SF Pro Display","Inter",sans-serif;
241 --fd-ease-out:cubic-bezier(.22,1,.36,1);--fd-dur-2:240ms;
242 --d-bg:#F2F0EB;--d-ink:#0B0B0F;--d-mut:#5E5C57;--d-line:rgba(11,11,15,.12);--d-em:#C2410C;
243 min-height:100vh;background:var(--d-bg);color:var(--d-ink);font:400 15px/1.45 var(--d-font);-webkit-font-smoothing:antialiased;transition:background-color var(--fd-dur-2) var(--fd-ease-out)}
244.fd-mag-demo[data-fd-theme="dark"]{--d-bg:#0D0D10;--d-ink:#F5F3EE;--d-mut:#A19F99;--d-line:rgba(255,255,255,.12);--d-em:#FF8A4C}
245.fd-mag-demo-bar{display:flex;align-items:center;justify-content:space-between;padding:20px clamp(16px,4vw,48px)}
246.fd-mag-demo-brand{font:600 17px/1 var(--d-disp);letter-spacing:-.02em}
247.fd-mag-demo-brand sup{font-size:10px;vertical-align:top;margin-left:2px}
248.fd-mag-demo-nav{display:flex;gap:32px;font:400 15px/1 var(--d-font);color:var(--d-mut)}
249.fd-mag-demo-nav a{padding:8px 0}.fd-mag-demo-nav a:hover{color:var(--d-ink)}
250.fd-mag-demo-nav a:focus-visible,.fd-mag-demo-theme:focus-visible{outline:2px solid rgba(10,132,255,.7);outline-offset:2px;border-radius:6px}
251.fd-mag-demo-theme{cursor:pointer;height:32px;padding:0 14px;border-radius:9999px;box-shadow:0 0 0 1px var(--d-line);font:500 13px/1 var(--d-font);color:var(--d-mut)}
252.fd-mag-demo-theme:hover{color:var(--d-ink)}
253.fd-mag-demo-main{padding:clamp(32px,8vh,96px) clamp(16px,4vw,48px) 48px;max-width:1240px;margin:0 auto}
254.fd-mag-demo-eyebrow{font:500 13px/1 var(--d-font);color:var(--d-mut);letter-spacing:.02em;text-transform:uppercase}
255.fd-mag-demo-h1{margin-top:28px;font:600 clamp(36px,7.4vw,88px)/.98 var(--d-disp);letter-spacing:-.045em;max-width:12.5em}
256.fd-mag-demo-h1 em{font-style:normal;color:var(--d-em)}
257.fd-mag-demo-cta{display:flex;align-items:center;gap:clamp(24px,5vw,72px);margin-top:clamp(40px,8vh,88px);flex-wrap:wrap}
258.fd-mag-demo-big{--fd-mag-size:164px}
259.fd-mag-demo-aside{display:flex;flex-direction:column;gap:20px;max-width:520px}
260.fd-mag-demo-aside p{font:400 17px/1.45 var(--d-font);letter-spacing:-.01em;color:var(--d-mut)}
261.fd-mag-demo-links{display:flex;flex-wrap:wrap;gap:12px}
262.fd-mag-demo-facts{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:24px;margin-top:clamp(48px,10vh,112px);padding-top:24px;border-top:1px solid var(--d-line)}
263.fd-mag-demo-facts dt{font:500 12px/1 var(--d-font);text-transform:uppercase;letter-spacing:.04em;color:var(--d-mut);margin-bottom:10px}
264.fd-mag-demo-facts dd{font:400 15px/1.4 var(--d-font)}
265@media (max-width:720px){.fd-mag-demo-nav{display:none}.fd-mag-demo-facts{grid-template-columns:1fr}}
266`;

More Buttons. Same file contract.

Button Core

The complete button system: 5 variants, 3 sizes, icon slots, loading → success morph and a springy press.

Shimmer Button

A dark capsule with a travelling light sweep and a rotating border glow for your one important call to action.

Hold to Confirm

Press and hold to confirm dangerous actions. Progress fills through the button, early release cancels, Space works too.