Split Text
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.
Browser support · Evergreen browsers (individual translate property). Reduced motion: no auto-cycle, 120ms fades.
npx shadcn@latest add https://freedesignmd.com/r/text-morph.json1/**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 blur4 * and fade out or in. The slot width eases with the letters, so a centred5 * line stays centred instead of jumping.6 *7 * Props8 * - 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) => void13 * - 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 the17 * 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 animating89 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-deps110 }, [words.join("|")]);111 112 // morph on word change113 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-deps138 }, [word]);139 140 // auto-cycle (uncontrolled), paused off-screen / hidden / reduced motion141 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 <span165 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 Primer243 </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 repo252 <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"} animation270 </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`;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.
A decoding effect: characters cycle through glyphs and lock in left to right. Hover re-scrambles; layout never shifts.