Split Text
Split any string into chars, words or lines and reveal it with rise, wave, scatter, blur or a human-paced typewriter.
A headline whose letters drift away from — or toward — your cursor and spring home, each on its own spring.
Browser support · All evergreen browsers. Pointer-only: still on touch and with reduced motion.
npx shadcn@latest add https://freedesignmd.com/r/text-repel.json1/**2 * TextRepel — a headline whose letters drift away from the cursor (or toward3 * 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 the5 * line feels like it is made of something physical.6 *7 * Props8 * - 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 resize19 * 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 transforms80 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 frame104 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 cursor119 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 // write133 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>&</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 & 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 Dutch232 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 work236 <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`;Split any string into chars, words or lines and reveal it with rise, wave, scatter, blur or a human-paced typewriter.
Cycle through words with a letter-level morph: shared letters glide into place, the rest blur away, the line stays centred.
A decoding effect: characters cycle through glyphs and lock in left to right. Hover re-scrambles; layout never shifts.