Button Core
The complete button system: 5 variants, 3 sizes, icon slots, loading → success morph and a springy press.
A button that leans toward your cursor on a spring, with the label drifting further than the shell.
Browser support · All evergreen browsers. Attraction is pointer-only and off for touch and reduced motion.
npx shadcn@latest add https://freedesignmd.com/r/magnetic-button.json1/**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 of7 * attraction beyond the button edge, default 80), shape ("pill" | "circle"),8 * size ("md" | "lg" | "xl"), tone ("solid" | "outline"), theme, plus native9 * <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 for13 * touch input and for prefers-reduced-motion; the button stays fully usable.14 * Accessibility: native <button>/<a>, focus ring, no transforms affect the15 * hit target semantics. The rAF loop runs only while the button is moving and16 * 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 ...rest43}: 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 stability68 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 & 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 <button198 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 & booking</dd></div>228 <div><dt>Awards</dt><dd>D&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`;The complete button system: 5 variants, 3 sizes, icon slots, loading → success morph and a springy press.
A dark capsule with a travelling light sweep and a rotating border glow for your one important call to action.
Press and hold to confirm dangerous actions. Progress fills through the button, early release cancels, Space works too.