Pull Button
Magnetic pull button for React: leans toward your cursor, the label moves further, then springs back. A tiny rAF spring, no library.
- Micro-interactions
- SaaS
- React only · 0 deps
- 1 file
Browser support · All evergreen browsers. The spring only runs while the button moves; touch and reduced motion switch the pull off.
Install. One command, or copy the file.
$
npx shadcn@latest add https://freedesignmd.com/r/pull-button.json- Byte-identical to the preview
- No Tailwind or config needed
- SSR-safe · keyboard accessible
- Respects reduced motion & transparency
components/micro-interactions/pull-button.tsx
1/**2 * PullButton, a button that leans toward the cursor as it gets close. The3 * label travels a little further than the shell, and the whole thing snaps4 * back on a spring when the pointer leaves.5 *6 * Props: every <button> prop, plus strength (0 to 1, default 0.35),7 * reach (px around the button where it starts to react, default 56),8 * variant ("solid" | "soft", default "solid").9 * CSS vars: --fd-pb-bg, --fd-pb-fg, --fd-pb-radius.10 *11 * Browser support: all evergreen browsers. A small requestAnimationFrame12 * spring runs only while the button is moving and stops by itself.13 * Accessibility: a normal button with a visible focus ring; the pull is14 * pointer-only and is switched off for touch and reduced motion.15 */16import { useEffect, useRef } from "react";17import type { ButtonHTMLAttributes } from "react";18 19export interface PullButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {20 strength?: number;21 reach?: number;22 variant?: "solid" | "soft";23}24 25export function PullButton({26 strength = 0.35,27 reach = 56,28 variant = "solid",29 className,30 children,31 ...rest32}: PullButtonProps) {33 const zone = useRef<HTMLSpanElement>(null);34 const shell = useRef<HTMLSpanElement>(null);35 const label = useRef<HTMLSpanElement>(null);36 const s = useRef({ x: 0, y: 0, vx: 0, vy: 0, tx: 0, ty: 0, raf: 0, last: 0 });37 38 useEffect(() => {39 const z = zone.current;40 const sh = shell.current;41 const lb = label.current;42 if (!z || !sh || !lb) return;43 if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;44 const st = s.current;45 46 const apply = () => {47 sh.style.transform = `translate3d(${st.x.toFixed(2)}px, ${st.y.toFixed(2)}px, 0)`;48 lb.style.transform = `translate3d(${(st.x * 0.45).toFixed(2)}px, ${(st.y * 0.45).toFixed(2)}px, 0)`;49 };50 const tick = (t: number) => {51 const dt = Math.min((t - st.last) / 1000 || 0.016, 0.032);52 st.last = t;53 const k = 190;54 const c = 15;55 st.vx += ((st.tx - st.x) * k - st.vx * c) * dt;56 st.vy += ((st.ty - st.y) * k - st.vy * c) * dt;57 st.x += st.vx * dt;58 st.y += st.vy * dt;59 apply();60 const settled = Math.abs(st.vx) + Math.abs(st.vy) < 0.05 && Math.abs(st.tx - st.x) + Math.abs(st.ty - st.y) < 0.05;61 if (settled) {62 st.x = st.tx;63 st.y = st.ty;64 apply();65 st.raf = 0;66 } else st.raf = requestAnimationFrame(tick);67 };68 const wake = () => {69 if (!st.raf) {70 st.last = performance.now();71 st.raf = requestAnimationFrame(tick);72 }73 };74 const onMove = (e: PointerEvent) => {75 if (e.pointerType === "touch") return;76 const r = sh.getBoundingClientRect();77 const cx = r.left - st.x + r.width / 2;78 const cy = r.top - st.y + r.height / 2;79 const dx = e.clientX - cx;80 const dy = e.clientY - cy;81 const insideX = Math.abs(dx) < r.width / 2 + reach;82 const insideY = Math.abs(dy) < r.height / 2 + reach;83 if (insideX && insideY) {84 st.tx = dx * strength;85 st.ty = dy * strength;86 } else {87 st.tx = 0;88 st.ty = 0;89 }90 wake();91 };92 const onLeave = () => {93 st.tx = 0;94 st.ty = 0;95 wake();96 };97 window.addEventListener("pointermove", onMove, { passive: true });98 z.addEventListener("pointerleave", onLeave);99 return () => {100 window.removeEventListener("pointermove", onMove);101 z.removeEventListener("pointerleave", onLeave);102 cancelAnimationFrame(st.raf);103 st.raf = 0;104 };105 }, [strength, reach]);106 107 return (108 <span ref={zone} className="fd-pb" style={{ padding: reach }}>109 <style>{css}</style>110 <button type="button" {...rest} className={`fd-pb-btn${className ? ` ${className}` : ""}`} data-variant={variant}>111 <span ref={shell} className="fd-pb-shell" aria-hidden="true" />112 <span ref={label} className="fd-pb-label">113 {children}114 </span>115 </button>116 </span>117 );118}119 120const FONT = `ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif`;121const css = `122.fd-pb,.fd-pb *,.fd-pb *::before,.fd-pb *::after{box-sizing:border-box}123.fd-pb{display:inline-block;margin:0;font:400 15px/1.2 ${FONT}}124.fd-pb-btn{--fd-pb-bg:#111;--fd-pb-fg:#fff;--fd-pb-radius:999px;position:relative;display:inline-flex;align-items:center;justify-content:center;margin:0;padding:15px 28px;border:0;border-radius:var(--fd-pb-radius);background:transparent;color:var(--fd-pb-fg);font:500 15px/1.2 ${FONT};letter-spacing:normal;text-decoration:none;cursor:pointer;appearance:none;-webkit-appearance:none;-webkit-tap-highlight-color:transparent}125.fd-pb-btn[data-variant="soft"]{--fd-pb-bg:rgba(255,255,255,.9);--fd-pb-fg:#1d1d1f}126.fd-pb-shell{position:absolute;inset:0;display:block;margin:0;padding:0;border-radius:inherit;background:var(--fd-pb-bg);box-shadow:0 1px 2px rgba(0,0,0,.14),0 10px 22px -10px rgba(0,0,0,.4);transition:scale 220ms cubic-bezier(0.22,1,0.36,1);will-change:transform}127.fd-pb-btn[data-variant="soft"] .fd-pb-shell{box-shadow:0 0 0 1px rgba(0,0,0,.06),0 8px 20px -10px rgba(0,0,0,.25)}128.fd-pb-btn:active .fd-pb-shell{scale:.95}129.fd-pb-btn:focus-visible{outline:2px solid #1a73e8;outline-offset:4px}130.fd-pb-label{position:relative;display:block;margin:0;padding:0;will-change:transform}131`;132 133export function Demo() {134 return (135 <div className="fd-pbd">136 <style>{demoCss}</style>137 <PullButton>Get started</PullButton>138 </div>139 );140}141 142const demoCss = `143.fd-pbd,.fd-pbd *,.fd-pbd *::before,.fd-pbd *::after{box-sizing:border-box}144.fd-pbd{display:flex;align-items:center;justify-content:center;width:100%;min-height:100vh;margin:0;padding:48px 20px;background:#ececec;font:400 15px/1.4 ${FONT};letter-spacing:normal;overflow:hidden}145`;More Micro-interactions. Same file contract.
Questions about Pull Button
Run npx shadcn@latest add https://freedesignmd.com/r/pull-button.json, or open the page and copy the file into your project. It has zero dependencies besides React. What you copy is the exact file that renders the live demo.
Magnetic pull button for React: leans toward your cursor, the label moves further, then springs back. A tiny rAF spring, no library. It is a free, MIT-licensed React micro-interactions component from the freedesignmd library.
Yes. It is free and MIT-licensed, use, modify and ship it in personal and commercial projects without attribution.
All evergreen browsers. The spring only runs while the button moves; touch and reduced motion switch the pull off.
Yes. It is a plain React component with its own styles, so it runs in Next.js, Vite, Remix and Lovable projects, and it needs no Tailwind setup. It has zero dependencies besides React.


