Text Morph

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

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

Browser support · Evergreen browsers (individual translate property). Reduced motion: no auto-cycle, 120ms fades.

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/text-morph.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/text/text-morph.tsx
1/**
2 * TextMorph — cycles through words and morphs between them letter by letter.
3 * Letters shared by both words glide to their new positions; the rest blur
4 * and fade out or in. The slot width eases with the letters, so a centred
5 * line stays centred instead of jumping.
6 *
7 * Props
8 * - words: string[] Words or short phrases to cycle through.
9 * - interval?: number ms each word is held. Default 2600.
10 * - index?: number Controlled index (disables auto-cycling).
11 * - paused?: boolean Pause auto-cycling (also paused off-screen and on reduced motion).
12 * - onIndexChange?: (i) => void
13 * - className / style Size it with font-size; the slot inherits typography.
14 * CSS vars on root: --fd-tm-dur (default 560ms), --fd-tm-blur (default 8px).
15 *
16 * Accessibility: letters are aria-hidden; a visually hidden copy carries the
17 * current word without a live region (no announcements every few seconds).
18 * Give cycling heroes a pause control (WCAG 2.2.2) — see the Demo.
19 * Reduced motion: words swap with a 120ms cross-fade and don't auto-cycle.
20 * Browser support: evergreen browsers (individual `translate` property:
21 * Chrome 104, Safari 14.1, Firefox 72).
22 */
23import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
24import type { CSSProperties } from "react";
25
26type Glyph = { id: number; ch: string; x: number; state: "in" | "stay" | "out" };
27type Metrics = { xs: number[][]; widths: number[] };
28
29export interface TextMorphProps {
30 words: string[];
31 interval?: number;
32 index?: number;
33 paused?: boolean;
34 onIndexChange?: (i: number) => void;
35 className?: string;
36 style?: CSSProperties;
37}
38
39const useIso = typeof window === "undefined" ? useEffect : useLayoutEffect;
40
41/** Pair each letter of `to` with an unused identical letter of `from`, preferring similar relative positions. */
42function match(from: string, to: string) {
43 const used = new Set<number>();
44 const pairs: number[] = [];
45 for (let j = 0; j < to.length; j++) {
46 let best = -1;
47 let bestD = Infinity;
48 for (let i = 0; i < from.length; i++) {
49 if (used.has(i) || from[i] !== to[j] || to[j] === " ") continue;
50 const d = Math.abs(i / Math.max(1, from.length) - j / Math.max(1, to.length));
51 if (d < bestD) {
52 bestD = d;
53 best = i;
54 }
55 }
56 if (best >= 0) used.add(best);
57 pairs.push(best);
58 }
59 return pairs;
60}
61
62export function TextMorph({ words, interval = 2600, index, paused, onIndexChange, className, style }: TextMorphProps) {
63 const root = useRef<HTMLSpanElement>(null);
64 const measurer = useRef<HTMLSpanElement>(null);
65 const metrics = useRef<Metrics | null>(null);
66 const [auto, setAuto] = useState(0);
67 const cur = index ?? auto;
68 const word = words[((cur % words.length) + words.length) % words.length] ?? "";
69 const [glyphs, setGlyphs] = useState<Glyph[]>(() => Array.from(word).map((ch, i) => ({ id: i, ch, x: 0, state: "stay" as const })));
70 const [width, setWidth] = useState<number | null>(null);
71 const prev = useRef({ word, glyphs });
72 const nextId = useRef(1000);
73 const visible = useRef(true);
74 const autoRef = useRef(0);
75 const cbRef = useRef(onIndexChange);
76 cbRef.current = onIndexChange;
77
78 const measure = () => {
79 const m = measurer.current;
80 if (!m) return;
81 const rows = Array.from(m.children) as HTMLElement[];
82 metrics.current = {
83 xs: rows.map((r) => Array.from(r.children).map((c) => (c as HTMLElement).offsetLeft)),
84 widths: rows.map((r) => r.offsetWidth),
85 };
86 };
87
88 // initial + resize measurement; place current glyphs without animating
89 useIso(() => {
90 const el = root.current;
91 if (!el) return;
92 const place = () => {
93 measure();
94 const wi = words.indexOf(prev.current.word);
95 const mt = metrics.current;
96 if (!mt || wi < 0) return;
97 el.setAttribute("data-snap", "");
98 const g = Array.from(prev.current.word).map((ch, i) => ({ id: nextId.current++, ch, x: mt.xs[wi][i] ?? 0, state: "stay" as const }));
99 prev.current.glyphs = g;
100 setGlyphs(g);
101 setWidth(mt.widths[wi]);
102 requestAnimationFrame(() => requestAnimationFrame(() => el.removeAttribute("data-snap")));
103 };
104 place();
105 const ro = new ResizeObserver(place);
106 if (measurer.current) ro.observe(measurer.current);
107 document.fonts?.ready.then(place).catch(() => {});
108 return () => ro.disconnect();
109 // eslint-disable-next-line react-hooks/exhaustive-deps
110 }, [words.join("|")]);
111
112 // morph on word change
113 useIso(() => {
114 const from = prev.current;
115 if (from.word === word) return;
116 const mt = metrics.current;
117 const wi = words.indexOf(word);
118 if (!mt || wi < 0) return;
119 const live = from.glyphs.filter((g) => g.state !== "out");
120 const pairs = match(live.map((g) => g.ch).join(""), word);
121 const taken = new Set<number>();
122 const next: Glyph[] = Array.from(word).map((ch, j) => {
123 const k = pairs[j];
124 if (k >= 0) {
125 taken.add(k);
126 return { id: live[k].id, ch, x: mt.xs[wi][j], state: "stay" };
127 }
128 return { id: nextId.current++, ch, x: mt.xs[wi][j], state: "in" };
129 });
130 const leaving: Glyph[] = live.filter((_, k) => !taken.has(k)).map((g) => ({ ...g, state: "out" }));
131 const all = [...next, ...leaving];
132 prev.current = { word, glyphs: next };
133 setGlyphs(all);
134 setWidth(mt.widths[wi]);
135 const t = window.setTimeout(() => setGlyphs((gs) => gs.filter((g) => g.state !== "out")), 640);
136 return () => window.clearTimeout(t);
137 // eslint-disable-next-line react-hooks/exhaustive-deps
138 }, [word]);
139
140 // auto-cycle (uncontrolled), paused off-screen / hidden / reduced motion
141 useEffect(() => {
142 if (index !== undefined || paused || words.length < 2) return;
143 const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
144 if (reduced) return;
145 const el = root.current;
146 let io: IntersectionObserver | null = null;
147 if (el && "IntersectionObserver" in window) {
148 io = new IntersectionObserver((es) => (visible.current = es.some((e) => e.isIntersecting)));
149 io.observe(el);
150 }
151 const id = window.setInterval(() => {
152 if (!visible.current || document.hidden) return;
153 autoRef.current = (autoRef.current + 1) % words.length;
154 setAuto(autoRef.current);
155 cbRef.current?.(autoRef.current);
156 }, interval);
157 return () => {
158 window.clearInterval(id);
159 io?.disconnect();
160 };
161 }, [index, paused, interval, words.length]);
162
163 return (
164 <span
165 ref={root}
166 className={`fd-tm${className ? " " + className : ""}`}
167 data-ready={width == null ? undefined : ""}
168 style={{ ...style, width: width == null ? undefined : `${width}px` }}
169 >
170 <style>{css}</style>
171 <span className="fd-tm-sr">{word}</span>
172 <span className="fd-tm-measure" ref={measurer} aria-hidden="true">
173 {words.map((w, i) => (
174 <span key={i} className="fd-tm-mrow">
175 {Array.from(w).map((ch, j) => (
176 <span key={j} className="fd-tm-mch">{ch}</span>
177 ))}
178 </span>
179 ))}
180 </span>
181 <span className="fd-tm-ghost" aria-hidden="true">{word}</span>
182 <span className="fd-tm-stage" aria-hidden="true">
183 {glyphs.map((g) => (
184 <span key={g.id} className="fd-tm-g" data-state={g.state} style={{ translate: `${g.x}px 0` }}>
185 {g.ch === " " ? " " : g.ch}
186 </span>
187 ))}
188 </span>
189 </span>
190 );
191}
192
193const css = `
194.fd-tm,.fd-tm *,.fd-tm *::before,.fd-tm *::after{box-sizing:border-box}
195.fd-tm{--fd-ease-out:cubic-bezier(.22,1,.36,1);--fd-ease-in-out:cubic-bezier(.65,0,.35,1);--fd-tm-dur:560ms;--fd-tm-blur:8px;
196 position:relative;display:inline-block;vertical-align:baseline;margin:0;padding:0;white-space:nowrap;
197 transition:width var(--fd-tm-dur) var(--fd-ease-in-out)}
198.fd-tm[data-snap],.fd-tm[data-snap] *{transition:none!important;animation:none!important}
199.fd-tm-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}
200.fd-tm-measure{position:absolute;left:0;top:0;visibility:hidden;pointer-events:none;margin:0;padding:0;white-space:pre}
201.fd-tm-mrow{position:absolute;left:0;top:0;display:inline-block;margin:0;padding:0;white-space:pre}
202.fd-tm-mch{display:inline;margin:0;padding:0}
203.fd-tm-ghost{display:inline-block;visibility:hidden;width:0;margin:0;padding:0;white-space:pre}
204.fd-tm:not([data-ready]) .fd-tm-ghost{visibility:visible;width:auto}
205.fd-tm:not([data-ready]) .fd-tm-stage{visibility:hidden}
206.fd-tm-stage{position:absolute;left:0;top:0;bottom:0;display:block;margin:0;padding:0}
207.fd-tm-g{position:absolute;left:0;top:0;display:inline-block;margin:0;padding:0;white-space:pre;will-change:translate,opacity,filter;
208 transition:translate var(--fd-tm-dur) var(--fd-ease-in-out),opacity var(--fd-tm-dur) var(--fd-ease-out),filter var(--fd-tm-dur) var(--fd-ease-out)}
209.fd-tm-g[data-state="in"]{animation:fd-tm-in var(--fd-tm-dur) var(--fd-ease-out) calc(var(--fd-tm-dur) * .28) both}
210.fd-tm-g[data-state="out"]{animation:fd-tm-out calc(var(--fd-tm-dur) * .7) var(--fd-ease-out) both;pointer-events:none}
211@keyframes fd-tm-in{from{opacity:0;filter:blur(var(--fd-tm-blur));transform:translate3d(0,.22em,0) scale(.92)}to{opacity:1;filter:blur(0);transform:none}}
212@keyframes fd-tm-out{from{opacity:1;filter:blur(0);transform:none}to{opacity:0;filter:blur(var(--fd-tm-blur));transform:translate3d(0,-.18em,0) scale(.94)}}
213@media (prefers-reduced-motion:reduce){
214 .fd-tm,.fd-tm-g{transition:none}
215 .fd-tm-g[data-state="in"]{animation:fd-tm-fade 120ms linear both}
216 .fd-tm-g[data-state="out"]{animation:none;opacity:0}
217}
218@keyframes fd-tm-fade{from{opacity:0}to{opacity:1}}
219`;
220
221/* ───────────────────────────── demo ───────────────────────────── */
222
223const WORDS = ["founders", "agents", "teams", "everyone"];
224const SUBS = [
225 "Ship a coherent product from the first pitch deck to the hundredth screen.",
226 "A machine-readable DESIGN.md your coding agents follow to the pixel.",
227 "One source of truth for tokens, components and the decisions behind them.",
228 "Free, MIT-licensed and portable — bring it to any stack you like.",
229];
230
231export function Demo() {
232 const [paused, setPaused] = useState(false);
233 const [i, setI] = useState(0);
234 const words = useMemo(() => WORDS, []);
235 return (
236 <div className="fd-tm-demo">
237 <style>{demoCss}</style>
238 <div className="fd-tm-demo-glow" aria-hidden="true" />
239 <header className="fd-tm-demo-bar">
240 <span className="fd-tm-demo-brand">
241 <svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true"><rect x="2" y="2" width="20" height="20" rx="6" fill="#16151A" /><path d="M7 16.5V7.5h5.2a3.3 3.3 0 0 1 0 6.6H7" fill="none" stroke="#F6F4EF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /><circle cx="16.5" cy="16.5" r="1.6" fill="#FF5B2E" /></svg>
242 Primer
243 </span>
244 <nav className="fd-tm-demo-nav" aria-label="Primary">
245 <a href="#product">Product</a><a href="#agents">For agents</a><a href="#pricing">Pricing</a><a href="#changelog">Changelog</a>
246 </nav>
247 <a className="fd-tm-demo-sign" href="#signin">Sign in</a>
248 </header>
249 <main className="fd-tm-demo-main">
250 <a className="fd-tm-demo-pill" href="#launch">
251 <span className="fd-tm-demo-new">New</span> Primer 2.0 — tokens that sync to Figma and your repo
252 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" aria-hidden="true"><path d="M9 6l6 6-6 6" /></svg>
253 </a>
254 <h1 className="fd-tm-demo-h1">
255 <span className="fd-tm-demo-pre">Design for</span>{" "}
256 <TextMorph words={words} paused={paused} onIndexChange={setI} className="fd-tm-demo-word" />
257 </h1>
258 <p className="fd-tm-demo-sub" key={i}>{SUBS[i]}</p>
259 <div className="fd-tm-demo-cta">
260 <a className="fd-tm-demo-btn" href="#start">Start free</a>
261 <a className="fd-tm-demo-btn fd-tm-demo-btn--ghost" href="#demo">Book a 20-min demo</a>
262 </div>
263 <button type="button" className="fd-tm-demo-pause" onClick={() => setPaused((p) => !p)} aria-pressed={paused}>
264 {paused ? (
265 <svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><path d="M2 1l7 4-7 4z" fill="currentColor" /></svg>
266 ) : (
267 <svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true"><rect x="1.5" y="1" width="2.4" height="8" rx=".6" fill="currentColor" /><rect x="6.1" y="1" width="2.4" height="8" rx=".6" fill="currentColor" /></svg>
268 )}
269 {paused ? "Resume" : "Pause"} animation
270 </button>
271 <ul className="fd-tm-demo-logos" aria-label="Teams using Primer">
272 {["Northwind Bank", "Lumen Health", "Kepler Freight", "Oda Hotels", "Tessellate"].map((n) => (
273 <li key={n}>{n}</li>
274 ))}
275 </ul>
276 </main>
277 </div>
278 );
279}
280
281const demoCss = `
282.fd-tm-demo,.fd-tm-demo *,.fd-tm-demo *::before,.fd-tm-demo *::after{box-sizing:border-box}
283.fd-tm-demo{--d-font:ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;
284 --d-disp:"SF Pro Display",ui-sans-serif,-apple-system,"Inter Display","Inter",sans-serif;
285 --d-ink:#16151A;--d-mut:#6B6A72;--d-line:rgba(22,21,26,.1);--d-acc:#FF5B2E;
286 position:relative;overflow:hidden;margin:0;padding:0;min-height:100vh;background:#F6F4EF;color:var(--d-ink);font:400 15px/1.5 var(--d-font);-webkit-font-smoothing:antialiased}
287.fd-tm-demo-glow{position:absolute;inset:-20% -10% auto;height:80%;pointer-events:none;
288 background:radial-gradient(40% 50% at 50% 60%,rgba(255,91,46,.16),transparent 70%),radial-gradient(30% 40% at 30% 40%,rgba(120,110,255,.12),transparent 70%)}
289.fd-tm-demo-bar{position:relative;display:flex;align-items:center;justify-content:space-between;gap:16px;margin:0;padding:20px clamp(16px,4vw,48px)}
290.fd-tm-demo-brand{display:inline-flex;align-items:center;gap:10px;margin:0;font:600 17px/1 var(--d-disp);letter-spacing:-.01em}
291.fd-tm-demo-brand svg{display:block}
292.fd-tm-demo-nav{display:flex;gap:28px;margin:0;padding:0}
293.fd-tm-demo-nav a,.fd-tm-demo-sign{margin:0;padding:6px 0;font:400 15px/1 var(--d-font);color:var(--d-mut);text-decoration:none}
294.fd-tm-demo-nav a:hover,.fd-tm-demo-sign:hover{color:var(--d-ink)}
295.fd-tm-demo-main{position:relative;display:flex;flex-direction:column;align-items:center;margin:0 auto;padding:clamp(48px,12vh,128px) 16px 48px;max-width:1200px;text-align:center}
296.fd-tm-demo-pill{display:inline-flex;align-items:center;gap:10px;margin:0;padding:6px 14px 6px 6px;border-radius:9999px;background:rgba(255,255,255,.7);
297 box-shadow:0 0 0 1px var(--d-line),0 6px 20px -12px rgba(22,21,26,.3);color:var(--d-ink);font:500 13px/1.2 var(--d-font);text-decoration:none;text-align:left}
298.fd-tm-demo-pill svg{display:block;flex:none;color:var(--d-mut)}
299.fd-tm-demo-new{display:inline-block;margin:0;padding:4px 8px;border-radius:9999px;background:var(--d-ink);color:#F6F4EF;font:600 12px/1 var(--d-font)}
300.fd-tm-demo-h1{margin:32px 0 0;padding:0;color:var(--d-ink);font:600 clamp(44px,8.6vw,120px)/1 var(--d-disp);letter-spacing:-.05em;text-wrap:balance}
301.fd-tm-demo-pre{display:inline-block;margin:0;padding:0}
302.fd-tm-demo-word{color:var(--d-acc);font-style:italic;font-weight:600;font-family:ui-serif,"New York","Iowan Old Style",Georgia,serif;letter-spacing:-.04em;padding-right:.04em}
303.fd-tm-demo-sub{margin:28px 0 0;max-width:30em;min-height:3em;color:var(--d-mut);font:400 clamp(17px,1.6vw,20px)/1.45 var(--d-font);letter-spacing:-.01em;text-wrap:balance;
304 animation:fd-tm-demo-sub 560ms cubic-bezier(.22,1,.36,1) both}
305@keyframes fd-tm-demo-sub{from{opacity:0;transform:translate3d(0,6px,0)}to{opacity:1;transform:none}}
306.fd-tm-demo-cta{display:flex;flex-wrap:wrap;justify-content:center;gap:12px;margin:32px 0 0}
307.fd-tm-demo-btn{display:inline-flex;align-items:center;height:48px;margin:0;padding:0 24px;border-radius:9999px;background:var(--d-ink);color:#F6F4EF;
308 font:500 15px/1 var(--d-font);text-decoration:none;transition:transform 120ms cubic-bezier(.22,1,.36,1)}
309.fd-tm-demo-btn:active{transform:scale(.96);transition-timing-function:cubic-bezier(.34,1.56,.64,1)}
310.fd-tm-demo-btn--ghost{background:rgba(255,255,255,.7);color:var(--d-ink);box-shadow:0 0 0 1px var(--d-line)}
311.fd-tm-demo-pause{appearance:none;-webkit-appearance:none;display:inline-flex;align-items:center;gap:8px;margin:28px 0 0;height:30px;padding:0 12px;border:0;border-radius:9999px;
312 background:transparent;color:var(--d-mut);font:500 12px/1 var(--d-font);cursor:pointer}
313.fd-tm-demo-pause:hover{color:var(--d-ink);background:rgba(22,21,26,.05)}
314.fd-tm-demo-pause svg{display:block}
315.fd-tm-demo a:focus-visible,.fd-tm-demo button:focus-visible{outline:2px solid rgba(10,132,255,.7);outline-offset:2px}
316.fd-tm-demo-logos{display:flex;flex-wrap:wrap;justify-content:center;gap:12px 40px;margin:clamp(40px,9vh,96px) 0 0;padding:0;list-style:none}
317.fd-tm-demo-logos li{margin:0;padding:0;color:rgba(22,21,26,.42);font:600 17px/1 var(--d-disp);letter-spacing:-.02em}
318@media (max-width:760px){.fd-tm-demo-nav{display:none}.fd-tm-demo-h1{font-size:clamp(40px,12vw,64px)}.fd-tm-demo-pre{display:block}}
319`;

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 Repel

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

Scramble Text

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