Skip to content

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
Full screen

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. The
3 * label travels a little further than the shell, and the whole thing snaps
4 * 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 requestAnimationFrame
12 * spring runs only while the button is moving and stops by itself.
13 * Accessibility: a normal button with a visible focus ring; the pull is
14 * 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 ...rest
32}: 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.

Card Stack, micro interactions preview: Card stack for React: photo cards you throw to the back with a drag or a flick, tap to pull one forward

Card Stack

Card stack for React: photo cards you throw to the back with a drag or a flick, tap to pull one forward. No dependencies.

Photo Sleeves, micro interactions preview: Photo album sleeves for React: pictures peek out, lift on hover and fan open on click

Photo Sleeves

Photo album sleeves for React: pictures peek out, lift on hover and fan open on click. Frosted pocket, zero dependencies.

Tilt Card, micro interactions preview: 3D tilt card for React: leans toward the pointer with a soft sheen and a drifting photo

Tilt Card

3D tilt card for React: leans toward the pointer with a soft sheen and a drifting photo. Lightweight, no animation library.

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.