Text Repel

A headline whose letters drift away from — or toward — your cursor and spring home, each on its own spring.

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

Browser support · All evergreen browsers. Pointer-only: still on touch and with reduced motion.

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/text-repel.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/text/text-repel.tsx
1/**
2 * TextRepel — a headline whose letters drift away from the cursor (or toward
3 * it) and spring back home. Every letter runs its own spring (stiffness 380,
4 * damping 30 by default) with a little tilt from horizontal velocity, so the
5 * line feels like it is made of something physical.
6 *
7 * Props
8 * - text: string Headline text (also the accessible label).
9 * - as?: "h1" | "h2" | "p" | "div". Default "h1".
10 * - mode?: "repel" | "attract". Default "repel".
11 * - radius?: number Influence radius in px. Default 160.
12 * - strength?: number Max displacement in px at the cursor. Default 56.
13 * - stiffness?, damping? Spring settings. Defaults 380 / 30.
14 * - tilt?: boolean Rotate letters with their velocity. Default true.
15 * - className / style Typography comes from you (font-size, colour…).
16 * CSS vars: none needed; style the root like any heading.
17 *
18 * Performance: one rAF loop per instance, letter homes measured on resize
19 * only (offsetLeft/Top are transform-independent), reads happen before writes,
20 * the loop sleeps when every letter is at rest and pauses off-screen.
21 * Accessibility: a visually hidden copy carries the text, letters are aria-hidden.
22 * Reduced motion or coarse pointers: letters stay still.
23 * Browser support: all evergreen browsers.
24 */
25import { useEffect, useRef, useState } from "react";
26import type { CSSProperties, Ref } from "react";
27
28export interface TextRepelProps {
29 text: string;
30 as?: "h1" | "h2" | "p" | "div";
31 mode?: "repel" | "attract";
32 radius?: number;
33 strength?: number;
34 stiffness?: number;
35 damping?: number;
36 tilt?: boolean;
37 className?: string;
38 style?: CSSProperties;
39}
40
41export function TextRepel({
42 text,
43 as = "h1",
44 mode = "repel",
45 radius = 160,
46 strength = 56,
47 stiffness = 380,
48 damping = 30,
49 tilt = true,
50 className,
51 style,
52}: TextRepelProps) {
53 const root = useRef<HTMLElement>(null);
54 const opts = useRef({ mode, radius, strength, stiffness, damping, tilt });
55 opts.current = { mode, radius, strength, stiffness, damping, tilt };
56
57 useEffect(() => {
58 const el = root.current;
59 if (!el) return;
60 if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return;
61 const spans = Array.from(el.querySelectorAll<HTMLElement>(".fd-tr-c"));
62 const n = spans.length;
63 const hx = new Float32Array(n);
64 const hy = new Float32Array(n);
65 const x = new Float32Array(n);
66 const y = new Float32Array(n);
67 const vx = new Float32Array(n);
68 const vy = new Float32Array(n);
69 let px = -1e6;
70 let py = -1e6;
71 let inside = false;
72 let raf = 0;
73 let last = 0;
74 let visible = true;
75
76 const measure = () => {
77 for (let i = 0; i < n; i++) {
78 const s = spans[i];
79 // offsetParent is the root (position:relative); offsets ignore transforms
80 let ox = s.offsetLeft + s.offsetWidth / 2;
81 let oy = s.offsetTop + s.offsetHeight / 2;
82 const p = s.offsetParent as HTMLElement | null;
83 if (p && p !== el) {
84 ox += p.offsetLeft;
85 oy += p.offsetTop;
86 }
87 hx[i] = ox;
88 hy[i] = oy;
89 }
90 };
91 measure();
92 const ro = new ResizeObserver(measure);
93 ro.observe(el);
94 document.fonts?.ready.then(measure).catch(() => {});
95
96 const tick = (t: number) => {
97 raf = 0;
98 const dt = Math.min(0.032, (t - last) / 1000 || 0.016);
99 last = t;
100 const o = opts.current;
101 const R = o.radius;
102 const sign = o.mode === "attract" ? -1 : 1;
103 // read once per frame
104 const r = el.getBoundingClientRect();
105 const lx = px - r.left;
106 const ly = py - r.top;
107 let moving = false;
108 const h = dt / 2;
109 for (let i = 0; i < n; i++) {
110 let tx = 0;
111 let ty = 0;
112 if (inside) {
113 const dx = hx[i] - lx;
114 const dy = hy[i] - ly;
115 const d = Math.hypot(dx, dy) || 0.0001;
116 if (d < R) {
117 const mag = Math.pow(1 - d / R, 2) * o.strength;
118 // attract never overshoots the cursor
119 const m = sign > 0 ? mag : -Math.min(mag, d * 0.6);
120 tx = (dx / d) * m;
121 ty = (dy / d) * m;
122 }
123 }
124 for (let k = 0; k < 2; k++) {
125 vx[i] += (-o.stiffness * (x[i] - tx) - o.damping * vx[i]) * h;
126 vy[i] += (-o.stiffness * (y[i] - ty) - o.damping * vy[i]) * h;
127 x[i] += vx[i] * h;
128 y[i] += vy[i] * h;
129 }
130 if (Math.abs(x[i] - tx) > 0.05 || Math.abs(y[i] - ty) > 0.05 || Math.abs(vx[i]) > 0.5 || Math.abs(vy[i]) > 0.5) moving = true;
131 }
132 // write
133 for (let i = 0; i < n; i++) {
134 const rot = o.tilt ? Math.max(-24, Math.min(24, vx[i] * 0.04 + x[i] * 0.18)) : 0;
135 spans[i].style.transform = `translate3d(${x[i].toFixed(2)}px,${y[i].toFixed(2)}px,0) rotate(${rot.toFixed(2)}deg)`;
136 }
137 if ((moving || inside) && visible && !document.hidden) raf = requestAnimationFrame(tick);
138 };
139 const kick = () => {
140 if (!raf && visible) {
141 last = performance.now();
142 raf = requestAnimationFrame(tick);
143 }
144 };
145 const onMove = (e: PointerEvent) => {
146 if (e.pointerType === "touch" && e.buttons === 0) return;
147 px = e.clientX;
148 py = e.clientY;
149 const r = el.getBoundingClientRect();
150 const pad = opts.current.radius;
151 inside = px > r.left - pad && px < r.right + pad && py > r.top - pad && py < r.bottom + pad;
152 kick();
153 };
154 const onLeave = () => {
155 inside = false;
156 kick();
157 };
158 const io = new IntersectionObserver((es) => {
159 visible = es.some((e) => e.isIntersecting);
160 if (visible) kick();
161 });
162 io.observe(el);
163 window.addEventListener("pointermove", onMove, { passive: true });
164 document.documentElement.addEventListener("pointerleave", onLeave);
165 window.addEventListener("blur", onLeave);
166 return () => {
167 ro.disconnect();
168 io.disconnect();
169 window.removeEventListener("pointermove", onMove);
170 document.documentElement.removeEventListener("pointerleave", onLeave);
171 window.removeEventListener("blur", onLeave);
172 if (raf) cancelAnimationFrame(raf);
173 spans.forEach((s) => (s.style.transform = ""));
174 };
175 }, [text]);
176
177 const Tag = as as "h1";
178 const words = text.split(/(\s+)/);
179 return (
180 <Tag ref={root as Ref<HTMLHeadingElement>} className={`fd-tr${className ? " " + className : ""}`} style={style}>
181 <style>{css}</style>
182 <span className="fd-tr-sr">{text}</span>
183 {words.map((w, k) =>
184 /^\s+$/.test(w) ? (
185 " "
186 ) : (
187 <span key={k} className="fd-tr-w" aria-hidden="true">
188 {Array.from(w).map((ch, j) => (
189 <span key={j} className="fd-tr-c">{ch}</span>
190 ))}
191 </span>
192 ),
193 )}
194 </Tag>
195 );
196}
197
198const css = `
199:where(.fd-tr){margin:0;padding:0;border:0;background:none;color:inherit;
200 font:700 clamp(3rem,9vw,7rem)/.92 "SF Pro Display",ui-sans-serif,-apple-system,BlinkMacSystemFont,"Inter","Segoe UI",Roboto,sans-serif;letter-spacing:-.04em;text-wrap:balance}
201.fd-tr,.fd-tr *{box-sizing:border-box}
202.fd-tr{position:relative;display:block;user-select:none;-webkit-user-select:none;touch-action:pan-y}
203.fd-tr-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0}
204.fd-tr-w{position:relative;display:inline-block;margin:0;padding:0;white-space:nowrap}
205.fd-tr-c{display:inline-block;margin:0;padding:0;will-change:transform;transform-origin:50% 70%}
206`;
207
208/* ───────────────────────────── demo ───────────────────────────── */
209
210export function Demo() {
211 const [mode, setMode] = useState<"repel" | "attract">("repel");
212 return (
213 <div className="fd-tr-demo">
214 <style>{demoCss}</style>
215 <header className="fd-tr-demo-bar">
216 <span className="fd-tr-demo-brand">Marrow<span>&amp;</span>Pine</span>
217 <span className="fd-tr-demo-loc">Rotterdam — 51.92°N</span>
218 <div className="fd-tr-demo-seg" role="radiogroup" aria-label="Cursor field">
219 {(["repel", "attract"] as const).map((m) => (
220 <button key={m} type="button" role="radio" aria-checked={mode === m} onClick={() => setMode(m)}>
221 {m === "repel" ? "Repel" : "Attract"}
222 </button>
223 ))}
224 </div>
225 </header>
226 <main className="fd-tr-demo-main">
227 <p className="fd-tr-demo-eyebrow">Independent brand &amp; motion studio · Est. 2014</p>
228 <TextRepel text="We make brands people can’t scroll past." mode={mode} className="fd-tr-demo-h1" radius={180} strength={mode === "repel" ? 64 : 36} />
229 <div className="fd-tr-demo-row">
230 <p className="fd-tr-demo-copy">
231 Identity, motion and digital product for companies that would rather be remembered than merely seen. Currently shaping launches for a Dutch
232 e-bike maker, a Lisbon hotel group and a climate fund.
233 </p>
234 <a className="fd-tr-demo-cta" href="#work">
235 See selected work
236 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6" /></svg>
237 </a>
238 </div>
239 <ul className="fd-tr-demo-tags" aria-label="Disciplines">
240 {["Brand systems", "Motion identity", "Type design", "Product UI", "Launch films"].map((t) => <li key={t}>{t}</li>)}
241 </ul>
242 </main>
243 <footer className="fd-tr-demo-foot">
244 <span>Clients incl. Veloq, Oda Hotels, Northbound Fund</span>
245 <span>hello@marrowpine.studio</span>
246 </footer>
247 </div>
248 );
249}
250
251const demoCss = `
252.fd-tr-demo,.fd-tr-demo *,.fd-tr-demo *::before,.fd-tr-demo *::after{box-sizing:border-box}
253.fd-tr-demo{--d-font:ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;--d-disp:"SF Pro Display",ui-sans-serif,-apple-system,"Inter Display","Inter",sans-serif;
254 --d-bg:#E9FF5B;--d-ink:#111110;--d-mut:rgba(17,17,16,.62);
255 display:flex;flex-direction:column;margin:0;padding:0;min-height:100vh;background:var(--d-bg);color:var(--d-ink);font:400 15px/1.5 var(--d-font);-webkit-font-smoothing:antialiased}
256.fd-tr-demo-bar{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:0;padding:20px clamp(16px,4vw,48px)}
257.fd-tr-demo-brand{margin:0;font:700 20px/1 var(--d-disp);letter-spacing:-.03em}
258.fd-tr-demo-brand span{margin:0 2px;font-style:italic;font-weight:400;font-family:ui-serif,Georgia,serif}
259.fd-tr-demo-loc{margin:0;font:500 13px/1 ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;color:var(--d-mut)}
260.fd-tr-demo-seg{display:inline-flex;margin:0;padding:3px;border-radius:9999px;background:rgba(17,17,16,.08)}
261.fd-tr-demo-seg button{appearance:none;-webkit-appearance:none;margin:0;height:30px;padding:0 14px;border:0;border-radius:9999px;background:transparent;color:var(--d-mut);
262 font:500 13px/1 var(--d-font);cursor:pointer;transition:background-color 240ms cubic-bezier(.65,0,.35,1),color 240ms}
263.fd-tr-demo-seg button[aria-checked="true"]{background:var(--d-ink);color:var(--d-bg)}
264.fd-tr-demo button:focus-visible,.fd-tr-demo a:focus-visible{outline:2px solid rgba(10,132,255,.7);outline-offset:2px}
265.fd-tr-demo-main{flex:1;display:flex;flex-direction:column;justify-content:center;margin:0 auto;padding:clamp(24px,6vh,64px) clamp(16px,4vw,48px);width:100%;max-width:1440px}
266.fd-tr-demo-eyebrow{margin:0 0 24px;font:500 13px/1 ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;color:var(--d-mut);text-transform:uppercase;letter-spacing:.04em}
267.fd-tr-demo-h1{max-width:11ch;color:var(--d-ink);font:800 clamp(52px,10.4vw,152px)/.88 var(--d-disp);letter-spacing:-.055em;cursor:crosshair}
268.fd-tr-demo-row{display:flex;align-items:flex-end;justify-content:space-between;gap:32px;flex-wrap:wrap;margin:clamp(32px,6vh,64px) 0 0}
269.fd-tr-demo-copy{max-width:30em;margin:0;font:400 17px/1.5 var(--d-font);letter-spacing:-.01em;color:var(--d-ink)}
270.fd-tr-demo-cta{display:inline-flex;align-items:center;gap:10px;height:56px;margin:0;padding:0 26px;border-radius:9999px;background:var(--d-ink);color:var(--d-bg);
271 font:500 17px/1 var(--d-font);text-decoration:none;transition:transform 120ms cubic-bezier(.22,1,.36,1)}
272.fd-tr-demo-cta:active{transform:scale(.96)}
273.fd-tr-demo-cta svg{display:block}
274.fd-tr-demo-tags{display:flex;flex-wrap:wrap;gap:8px;margin:32px 0 0;padding:0;list-style:none}
275.fd-tr-demo-tags li{margin:0;padding:8px 14px;border-radius:9999px;box-shadow:inset 0 0 0 1px rgba(17,17,16,.3);font:500 13px/1 var(--d-font)}
276.fd-tr-demo-foot{display:flex;justify-content:space-between;gap:16px;flex-wrap:wrap;margin:0;padding:20px clamp(16px,4vw,48px);border-top:1px solid rgba(17,17,16,.16);font:500 13px/1.3 var(--d-font);color:var(--d-mut)}
277@media (max-width:640px){.fd-tr-demo-loc{display:none}}
278`;

More Text Effects. Same file contract.

Split Text

Split any string into chars, words or lines and reveal it with rise, wave, scatter, blur or a human-paced typewriter.

Text Morph

Cycle through words with a letter-level morph: shared letters glide into place, the rest blur away, the line stays centred.

Scramble Text

A decoding effect: characters cycle through glyphs and lock in left to right. Hover re-scrambles; layout never shifts.