Split Text

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

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

Browser support · All evergreen browsers. Reduced motion shows the text immediately.

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/split-text.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/text/split-text.tsx
1/**
2 * SplitText — splits a string into characters, words or lines and plays an
3 * entrance preset across the pieces.
4 *
5 * Presets
6 * - "rise" pieces slide up out of a clipping mask (per word).
7 * - "wave" a crest travels through the text: each piece lifts, overshoots, settles.
8 * - "scatter" pieces fly in from deterministic random offsets and rotations.
9 * - "blur" pieces sharpen from a soft blur while drifting up.
10 * - "typewriter" characters appear one by one with human timing (bursts within
11 * words, pauses after punctuation, the odd hesitation) and a caret.
12 *
13 * Props
14 * - text: string The content. Always the accessible text.
15 * - as?: "h1"…"h6" | "p" | "span" | "div" Element. Default "h2".
16 * - split?: "chars" | "words" | "lines" Default "chars" ("typewriter" always uses chars).
17 * - preset?: see above. Default "rise".
18 * - trigger?: "view" | "mount" | "manual". Default "view" (35% visible, once).
19 * - replay?: number | string Change it to replay (on-demand trigger).
20 * - stagger?: number ms between pieces. Defaults per split: 22 / 70 / 140.
21 * - duration?: number ms per piece. Default 700 (--fd-dur-4).
22 * - delay?: number ms before the first piece. Default 0.
23 * - cps?: number Typewriter speed in characters per second. Default 20.
24 * - caret?: boolean Typewriter caret. Default true.
25 * - onDone?: () => void Fires once the last piece has landed.
26 * - ref?: { play(), reset() } Imperative control (React 19 ref-as-prop).
27 * CSS vars on root: --fd-st-caret (caret colour), --fd-st-blur (blur radius).
28 * Typography: inherits nothing; defaults live in :where() so any class you
29 * pass (font, size, colour) wins.
30 *
31 * Accessibility: headings carry aria-label with the full text; other tags get
32 * a visually hidden copy. All pieces are aria-hidden. Reduced motion shows the
33 * text immediately (the typewriter caret stays, without blinking).
34 * Browser support: all evergreen browsers.
35 */
36import { useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from "react";
37import type { CSSProperties, Ref } from "react";
38
39export type SplitPreset = "rise" | "wave" | "scatter" | "blur" | "typewriter";
40export type SplitMode = "chars" | "words" | "lines";
41export type SplitTextHandle = { play: () => void; reset: () => void };
42
43export interface SplitTextProps {
44 text: string;
45 as?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p" | "span" | "div";
46 split?: SplitMode;
47 preset?: SplitPreset;
48 trigger?: "view" | "mount" | "manual";
49 replay?: number | string;
50 stagger?: number;
51 duration?: number;
52 delay?: number;
53 cps?: number;
54 caret?: boolean;
55 onDone?: () => void;
56 className?: string;
57 style?: CSSProperties;
58 ref?: Ref<SplitTextHandle>;
59}
60
61// deterministic 0..1 noise, SSR-safe
62const hash = (i: number, s: number) => {
63 const x = Math.sin(i * 127.1 + s * 311.7) * 43758.5453;
64 return x - Math.floor(x);
65};
66const useIso = typeof window === "undefined" ? useEffect : useLayoutEffect;
67
68/** Human-ish keystroke schedule: returns cumulative ms per character. */
69function typeSchedule(text: string, cps: number) {
70 const base = 1000 / cps;
71 const out: number[] = [];
72 let t = 0;
73 let burst = 0;
74 for (let i = 0; i < text.length; i++) {
75 const c = text[i];
76 const prev = text[i - 1] ?? "";
77 let d = base * (0.45 + hash(i, 3) * 0.9);
78 if (burst > 0) {
79 d *= 0.55; // fingers already moving: quick run inside a word
80 burst--;
81 } else if (hash(i, 9) > 0.72) burst = 2 + Math.floor(hash(i, 4) * 4);
82 if (prev === " ") d += base * 0.35 * hash(i, 5);
83 if (/[,;:]/.test(prev)) d += base * (2.2 + hash(i, 6) * 1.5);
84 if (/[.!?—–]/.test(prev)) d += base * (4 + hash(i, 7) * 3);
85 if (hash(i, 8) > 0.965) d += base * (3 + hash(i, 2) * 4); // the hesitation
86 if (c === " ") d *= 0.7;
87 t += i === 0 ? 0 : d;
88 out.push(Math.round(t));
89 }
90 return out;
91}
92
93const HEADINGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]);
94
95export function SplitText({
96 text,
97 as = "h2",
98 split = "chars",
99 preset = "rise",
100 trigger = "view",
101 replay,
102 stagger,
103 duration = 700,
104 delay = 0,
105 cps = 20,
106 caret = true,
107 onDone,
108 className,
109 style,
110 ref,
111}: SplitTextProps) {
112 const root = useRef<HTMLElement>(null);
113 const timers = useRef<number[]>([]);
114 const doneRef = useRef(onDone);
115 doneRef.current = onDone;
116 const mode: SplitMode = preset === "typewriter" ? "chars" : split;
117 const step = stagger ?? (mode === "chars" ? 22 : mode === "words" ? 70 : 140);
118
119 // words → chars, keeping a global char index for staggering
120 const words = useMemo(() => {
121 let ci = 0;
122 return text.split(/(\s+)/).filter(Boolean).map((w) => {
123 const space = /^\s+$/.test(w);
124 const chars = Array.from(w).map((ch) => ({ ch, i: ci++ }));
125 return { w, space, chars };
126 });
127 }, [text]);
128 const total = useMemo(() => words.reduce((n, w) => n + w.chars.length, 0), [words]);
129 const wordCount = useMemo(() => words.filter((w) => !w.space).length, [words]);
130
131 const clear = () => {
132 timers.current.forEach((t) => window.clearTimeout(t));
133 timers.current = [];
134 };
135
136 // line indices for split="lines": measured, written straight to the DOM
137 useIso(() => {
138 const el = root.current;
139 if (!el || mode !== "lines") return;
140 const measure = () => {
141 const ws = el.querySelectorAll<HTMLElement>(".fd-st-w");
142 let line = -1;
143 let lastTop = -Infinity;
144 ws.forEach((w) => {
145 const top = w.offsetTop;
146 if (top > lastTop + 2) {
147 line++;
148 lastTop = top;
149 }
150 w.style.setProperty("--fd-st-i", String(line));
151 });
152 el.style.setProperty("--fd-st-n", String(line + 1));
153 };
154 measure();
155 const ro = new ResizeObserver(measure);
156 ro.observe(el);
157 return () => ro.disconnect();
158 }, [mode, text]);
159
160 const runTypewriter = useCallback(() => {
161 const el = root.current;
162 if (!el) return;
163 clear();
164 const cs = Array.from(el.querySelectorAll<HTMLElement>(".fd-st-c"));
165 cs.forEach((c) => {
166 c.removeAttribute("data-on");
167 c.removeAttribute("data-caret");
168 });
169 const lead = el.querySelector<HTMLElement>(".fd-st-lead");
170 lead?.setAttribute("data-caret", "");
171 el.setAttribute("data-typing", "");
172 const sched = typeSchedule(text, cps);
173 cs.forEach((c, i) => {
174 timers.current.push(
175 window.setTimeout(() => {
176 c.setAttribute("data-on", "");
177 (i === 0 ? lead : cs[i - 1])?.removeAttribute("data-caret");
178 c.setAttribute("data-caret", "");
179 if (i === cs.length - 1) {
180 el.removeAttribute("data-typing");
181 doneRef.current?.();
182 }
183 }, delay + sched[i]),
184 );
185 });
186 // pause the blink while keys are moving; resume shortly after each key
187 }, [text, cps, delay]);
188
189 const play = useCallback(() => {
190 const el = root.current;
191 if (!el) return;
192 const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
193 clear();
194 if (reduced) {
195 el.setAttribute("data-reduced", "");
196 if (preset === "typewriter") {
197 const cs = el.querySelectorAll<HTMLElement>(".fd-st-c");
198 cs.forEach((c) => c.setAttribute("data-on", ""));
199 el.querySelector(".fd-st-lead")?.removeAttribute("data-caret");
200 cs[cs.length - 1]?.setAttribute("data-caret", "");
201 }
202 el.dataset.phase = "in";
203 doneRef.current?.();
204 return;
205 }
206 if (preset === "typewriter") {
207 el.dataset.phase = "in";
208 runTypewriter();
209 return;
210 }
211 // restart: snap to idle without transitions, flush styles, then play
212 el.setAttribute("data-snap", "");
213 el.dataset.phase = "idle";
214 void el.offsetWidth;
215 el.removeAttribute("data-snap");
216 void el.offsetWidth;
217 el.dataset.phase = "in";
218 const n = mode === "chars" ? total : mode === "words" ? wordCount : Number(getComputedStyle(el).getPropertyValue("--fd-st-n")) || 3;
219 const spread = preset === "scatter" ? 260 : step * Math.max(0, n - 1);
220 timers.current.push(window.setTimeout(() => doneRef.current?.(), delay + spread + duration + 60));
221 }, [preset, mode, total, wordCount, step, duration, delay, runTypewriter]);
222
223 const reset = useCallback(() => {
224 const el = root.current;
225 clear();
226 if (!el) return;
227 el.setAttribute("data-snap", "");
228 el.dataset.phase = "idle";
229 el.querySelectorAll(".fd-st-c").forEach((c) => {
230 c.removeAttribute("data-on");
231 c.removeAttribute("data-caret");
232 });
233 el.querySelector(".fd-st-lead")?.setAttribute("data-caret", "");
234 requestAnimationFrame(() => el.removeAttribute("data-snap"));
235 }, []);
236
237 useImperativeHandle(ref, () => ({ play, reset }), [play, reset]);
238
239 // triggers
240 useEffect(() => {
241 const el = root.current;
242 if (!el) return;
243 if (trigger === "mount") {
244 play();
245 return clear;
246 }
247 if (trigger !== "view") return clear;
248 if (!("IntersectionObserver" in window)) {
249 play();
250 return clear;
251 }
252 const io = new IntersectionObserver(
253 (es) => {
254 if (es.some((e) => e.isIntersecting)) {
255 io.disconnect();
256 play();
257 }
258 },
259 { threshold: 0.35 },
260 );
261 io.observe(el);
262 return () => {
263 io.disconnect();
264 clear();
265 };
266 // eslint-disable-next-line react-hooks/exhaustive-deps
267 }, [trigger, text, preset, split]);
268
269 const first = useRef(true);
270 useEffect(() => {
271 if (first.current) {
272 first.current = false;
273 return;
274 }
275 play();
276 // eslint-disable-next-line react-hooks/exhaustive-deps
277 }, [replay]);
278
279 const Tag = as as "h2";
280 const isHeading = HEADINGS.has(as);
281 let wi = -1;
282
283 return (
284 <Tag
285 ref={root as Ref<HTMLHeadingElement>}
286 className={`fd-st${className ? " " + className : ""}`}
287 data-preset={preset}
288 data-split={mode}
289 data-phase="idle"
290 data-caret-on={preset === "typewriter" && caret ? "" : undefined}
291 aria-label={isHeading ? text : undefined}
292 style={{ ...style, ["--fd-st-dur" as string]: `${duration}ms`, ["--fd-st-step" as string]: `${step}ms`, ["--fd-st-delay" as string]: `${delay}ms` }}
293 >
294 <style>{css}</style>
295 {!isHeading && <span className="fd-st-sr">{text}</span>}
296 <span className="fd-st-inner" aria-hidden="true">
297 {preset === "typewriter" && <span className="fd-st-lead" data-caret="" />}
298 {words.map((w, k) => {
299 if (w.space) {
300 // spaces stay real text so lines wrap naturally; typewriter types them too
301 return preset === "typewriter" ? (
302 <span key={k} className="fd-st-c fd-st-sp">{w.w.replace(/\s+/g, " ")}</span>
303 ) : (
304 " "
305 );
306 }
307 wi++;
308 const pieces =
309 mode === "chars"
310 ? w.chars.map(({ ch, i }) => (
311 <span
312 key={i}
313 className="fd-st-c"
314 style={{
315 ["--fd-st-i" as string]: i,
316 ["--fd-st-x" as string]: `${((hash(i, 1) - 0.5) * 2.4).toFixed(3)}em`,
317 ["--fd-st-y" as string]: `${((hash(i, 2) - 0.5) * 1.8).toFixed(3)}em`,
318 ["--fd-st-r" as string]: `${((hash(i, 3) - 0.5) * 90).toFixed(1)}deg`,
319 ["--fd-st-j" as string]: hash(i, 4).toFixed(3),
320 }}
321 >
322 {ch}
323 </span>
324 ))
325 : w.w;
326 return (
327 <span
328 key={k}
329 className="fd-st-w"
330 style={
331 mode === "words"
332 ? {
333 ["--fd-st-i" as string]: wi,
334 ["--fd-st-x" as string]: `${((hash(wi, 1) - 0.5) * 1.6).toFixed(3)}em`,
335 ["--fd-st-y" as string]: `${((hash(wi, 2) - 0.5) * 1.4).toFixed(3)}em`,
336 ["--fd-st-r" as string]: `${((hash(wi, 3) - 0.5) * 40).toFixed(1)}deg`,
337 ["--fd-st-j" as string]: hash(wi, 4).toFixed(3),
338 }
339 : undefined
340 }
341 >
342 <span className="fd-st-wi">{pieces}</span>
343 </span>
344 );
345 })}
346 </span>
347 </Tag>
348 );
349}
350
351const css = `
352:where(.fd-st){margin:0;padding:0;border:0;background:none;color:inherit;
353 font:600 clamp(2rem,5vw,4rem)/1.04 "SF Pro Display",ui-sans-serif,-apple-system,BlinkMacSystemFont,"Inter","Segoe UI",Roboto,sans-serif;
354 letter-spacing:-.03em;text-wrap:balance}
355.fd-st,.fd-st *,.fd-st *::before,.fd-st *::after{box-sizing:border-box}
356.fd-st{--fd-ease-out:cubic-bezier(.22,1,.36,1);--fd-spring:cubic-bezier(.34,1.56,.64,1);--fd-st-blur:14px;--fd-st-caret:currentColor;
357 display:block;font-kerning:normal;font-feature-settings:"kern"}
358.fd-st-inner{display:inline;margin:0;padding:0}
359.fd-st-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}
360.fd-st-w{display:inline-block;margin:0;padding:0;white-space:nowrap;vertical-align:top}
361.fd-st-wi,.fd-st-c{display:inline-block;margin:0;padding:0;will-change:transform,opacity}
362.fd-st-c{white-space:pre}
363
364/* shared timing: chars/words use their index, lines use the measured line index */
365.fd-st[data-split="chars"] .fd-st-c,
366.fd-st[data-split="words"] .fd-st-w > .fd-st-wi,
367.fd-st[data-split="lines"] .fd-st-w > .fd-st-wi{
368 --fd-st-t:calc(var(--fd-st-delay) + var(--fd-st-i,0) * var(--fd-st-step));
369 transition:transform var(--fd-st-dur) var(--fd-ease-out) var(--fd-st-t),opacity var(--fd-st-dur) var(--fd-ease-out) var(--fd-st-t),filter var(--fd-st-dur) var(--fd-ease-out) var(--fd-st-t)}
370.fd-st[data-split="lines"] .fd-st-w{--fd-st-i:0}
371.fd-st[data-snap] *{transition:none!important;animation:none!important}
372
373/* rise: words are masks, the inner content slides up through them */
374.fd-st[data-preset="rise"] .fd-st-w{overflow:hidden;padding:.06em .02em .14em;margin:-.06em -.02em -.14em}
375.fd-st[data-preset="rise"][data-phase="idle"] .fd-st-wi,
376.fd-st[data-preset="rise"][data-split="chars"][data-phase="idle"] .fd-st-c{transform:translate3d(0,135%,0) rotate(4deg)}
377.fd-st[data-preset="rise"][data-split="chars"] .fd-st-wi{transform:none;transition:none}
378.fd-st[data-preset="rise"][data-split="chars"] .fd-st-c{transform-origin:0 100%}
379.fd-st[data-preset="rise"] .fd-st-wi{transform-origin:0 100%}
380
381/* blur */
382.fd-st[data-preset="blur"][data-phase="idle"] .fd-st-wi,
383.fd-st[data-preset="blur"][data-split="chars"][data-phase="idle"] .fd-st-c{opacity:0;filter:blur(var(--fd-st-blur));transform:translate3d(0,.18em,0) scale(1.04)}
384.fd-st[data-preset="blur"][data-split="chars"] .fd-st-wi{opacity:1;filter:none;transform:none}
385
386/* scatter: random offsets, a little extra random delay */
387.fd-st[data-preset="scatter"] .fd-st-c,.fd-st[data-preset="scatter"][data-split="words"] .fd-st-wi{
388 --fd-st-t:calc(var(--fd-st-delay) + var(--fd-st-j) * 260ms);transition-duration:calc(var(--fd-st-dur) * 1.25)}
389.fd-st[data-preset="scatter"][data-phase="idle"] .fd-st-c,
390.fd-st[data-preset="scatter"][data-split="words"][data-phase="idle"] .fd-st-wi{opacity:0;filter:blur(6px);
391 transform:translate3d(var(--fd-st-x),var(--fd-st-y),0) rotate(var(--fd-st-r)) scale(.6)}
392
393/* wave: a crest passes through, pieces overshoot and settle */
394.fd-st[data-preset="wave"][data-phase="idle"] .fd-st-c,
395.fd-st[data-preset="wave"][data-split="words"][data-phase="idle"] .fd-st-wi,
396.fd-st[data-preset="wave"][data-split="lines"][data-phase="idle"] .fd-st-wi{opacity:0;transform:translate3d(0,.55em,0)}
397.fd-st[data-preset="wave"][data-phase="in"] .fd-st-c,
398.fd-st[data-preset="wave"][data-split="words"][data-phase="in"] .fd-st-wi,
399.fd-st[data-preset="wave"][data-split="lines"][data-phase="in"] .fd-st-wi{animation:fd-st-wave var(--fd-st-dur) cubic-bezier(.3,.7,.4,1) var(--fd-st-t) both}
400@keyframes fd-st-wave{0%{opacity:0;transform:translate3d(0,.55em,0) scaleY(.9)}38%{opacity:1}55%{transform:translate3d(0,-.16em,0) scaleY(1.04)}78%{transform:translate3d(0,.03em,0)}100%{opacity:1;transform:none}}
401
402/* typewriter */
403.fd-st[data-preset="typewriter"] .fd-st-c{opacity:0;transition:none;will-change:auto}
404.fd-st[data-preset="typewriter"] .fd-st-c[data-on]{opacity:1}
405.fd-st[data-preset="typewriter"] .fd-st-w{white-space:nowrap}
406.fd-st-lead{display:inline-block;width:0;margin:0;padding:0}
407.fd-st-lead::before{content:"\\200b"}
408.fd-st[data-caret-on] [data-caret]{position:relative}
409.fd-st[data-caret-on] [data-caret]::after{content:"";position:absolute;top:.1em;bottom:.06em;right:-.1em;width:max(2px,.06em);border-radius:1px;
410 background:var(--fd-st-caret);opacity:1;animation:fd-st-blink 1.06s steps(1,end) infinite}
411.fd-st[data-caret-on] .fd-st-lead[data-caret]::after{right:auto;left:0}
412.fd-st[data-typing] [data-caret]::after{animation:none}
413@keyframes fd-st-blink{0%,55%{opacity:1}56%,100%{opacity:0}}
414
415@media (prefers-reduced-motion:reduce){
416 .fd-st *{transition:none!important;animation:none!important}
417 .fd-st[data-phase] .fd-st-wi,.fd-st[data-phase] .fd-st-c{opacity:1;transform:none;filter:none}
418 .fd-st[data-preset="typewriter"] .fd-st-c{opacity:1}
419}
420`;
421
422/* ───────────────────────────── demo ───────────────────────────── */
423
424const PRESETS: { id: SplitPreset; label: string }[] = [
425 { id: "rise", label: "Rise" },
426 { id: "wave", label: "Wave" },
427 { id: "scatter", label: "Scatter" },
428 { id: "blur", label: "Blur" },
429 { id: "typewriter", label: "Type" },
430];
431
432export function Demo() {
433 const [keys, setKeys] = useState<Record<string, number>>({});
434 const bump = (id: string) => setKeys((k) => ({ ...k, [id]: (k[id] ?? 0) + 1 }));
435 const replayAll = () => setKeys((k) => Object.fromEntries(PRESETS.map((p) => [p.id, (k[p.id] ?? 0) + 1])));
436
437 const Replay = ({ id, label }: { id: string; label: string }) => (
438 <button type="button" className="fd-st-demo-replay" onClick={() => bump(id)} aria-label={`Replay ${label} preset`}>
439 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
440 <path d="M3 12a9 9 0 1 0 3-6.7L3 8" />
441 <path d="M3 3v5h5" />
442 </svg>
443 {label}
444 </button>
445 );
446
447 return (
448 <div className="fd-st-demo">
449 <style>{demoCss}</style>
450 <header className="fd-st-demo-bar">
451 <span className="fd-st-demo-logo" aria-label="Halyard Research">
452 <svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="10.5" fill="none" stroke="currentColor" strokeWidth="1.6" /><path d="M5 15.5c3-5.5 11-5.5 14 0" fill="none" stroke="currentColor" strokeWidth="1.6" /><circle cx="12" cy="8" r="1.8" fill="currentColor" /></svg>
453 Halyard
454 </span>
455 <nav className="fd-st-demo-nav" aria-label="Primary">
456 <a href="#research">Research</a>
457 <a href="#models">Models</a>
458 <a href="#safety">Safety</a>
459 <a href="#careers">Careers</a>
460 </nav>
461 <button type="button" className="fd-st-demo-all" onClick={replayAll}>Replay all</button>
462 </header>
463
464 <main className="fd-st-demo-main">
465 <section className="fd-st-demo-hero">
466 <div className="fd-st-demo-kick">
467 <span className="fd-st-demo-dot" aria-hidden="true" />
468 Release note · 14 November 2026
469 <Replay id="rise" label="Rise · lines" />
470 </div>
471 <SplitText
472 as="h1"
473 preset="rise"
474 split="lines"
475 text="Halyard 3 reasons in long arcs, not short hops."
476 className="fd-st-demo-h1"
477 trigger="mount"
478 replay={keys.rise}
479 />
480 <SplitText
481 as="p"
482 preset="blur"
483 split="words"
484 stagger={28}
485 delay={380}
486 text="A 1.2M-token context, verified tool use and a planning head trained on 41 billion steps of agentic traces. Available today in the API and on-prem."
487 className="fd-st-demo-lede"
488 trigger="mount"
489 replay={keys.rise}
490 />
491 </section>
492
493 <section className="fd-st-demo-grid" aria-label="Highlights">
494 <article className="fd-st-demo-card fd-st-demo-card--stat">
495 <div className="fd-st-demo-cardhead"><span>Long-horizon eval</span><Replay id="scatter" label="Scatter" /></div>
496 <SplitText as="p" preset="scatter" text="+38.4%" className="fd-st-demo-stat" replay={keys.scatter} />
497 <p className="fd-st-demo-note">Task completion on HorizonBench-500 over Halyard 2, with no change to latency at p50.</p>
498 </article>
499
500 <article className="fd-st-demo-card fd-st-demo-card--quote">
501 <div className="fd-st-demo-cardhead"><span>From the paper</span><Replay id="wave" label="Wave" /></div>
502 <SplitText
503 as="h2"
504 preset="wave"
505 text="Plans should bend, not break."
506 className="fd-st-demo-quote"
507 duration={900}
508 stagger={30}
509 replay={keys.wave}
510 />
511 <p className="fd-st-demo-note">Dr. Ines Achterberg, Head of Reasoning</p>
512 </article>
513
514 <article className="fd-st-demo-card fd-st-demo-card--term">
515 <div className="fd-st-demo-cardhead"><span>halyard-3 · playground</span><Replay id="typewriter" label="Type" /></div>
516 <div className="fd-st-demo-term">
517 <span className="fd-st-demo-prompt" aria-hidden="true">›</span>
518 <SplitText
519 as="p"
520 preset="typewriter"
521 cps={24}
522 delay={300}
523 text="Plan a 3-week migration of our billing service to event sourcing, with a rollback at every step."
524 className="fd-st-demo-typed"
525 replay={keys.typewriter}
526 />
527 </div>
528 </article>
529 </section>
530
531 <section className="fd-st-demo-foot">
532 <div className="fd-st-demo-cardhead"><span>Research agenda</span><Replay id="blur" label="Blur · words" /></div>
533 <SplitText
534 as="h2"
535 preset="blur"
536 split="words"
537 text="Interpretability you can audit. Evaluations you can rerun. Weights you can host."
538 className="fd-st-demo-h2"
539 replay={keys.blur}
540 />
541 </section>
542 </main>
543 </div>
544 );
545}
546
547const demoCss = `
548.fd-st-demo,.fd-st-demo *,.fd-st-demo *::before,.fd-st-demo *::after{box-sizing:border-box}
549.fd-st-demo{--d-font:ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;
550 --d-disp:"SF Pro Display",ui-sans-serif,-apple-system,"Inter Display","Inter",sans-serif;
551 --d-serif:ui-serif,"New York","Iowan Old Style","Charter","Georgia",serif;
552 --d-mono:ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;
553 --d-bg:#F3F1EC;--d-ink:#101014;--d-mut:#64625C;--d-line:rgba(16,16,20,.12);--d-acc:#E0461B;
554 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}
555.fd-st-demo-bar{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:0;padding:20px clamp(16px,4vw,48px)}
556.fd-st-demo-logo{display:inline-flex;align-items:center;gap:10px;margin:0;font:600 17px/1 var(--d-disp);letter-spacing:-.01em;color:var(--d-ink)}
557.fd-st-demo-logo svg{display:block}
558.fd-st-demo-nav{display:flex;gap:28px;margin:0;padding:0}
559.fd-st-demo-nav a{margin:0;padding:6px 0;font:400 15px/1 var(--d-font);color:var(--d-mut);text-decoration:none}
560.fd-st-demo-nav a:hover{color:var(--d-ink)}
561.fd-st-demo-all{appearance:none;-webkit-appearance:none;margin:0;height:34px;padding:0 16px;border:0;border-radius:9999px;background:var(--d-ink);color:var(--d-bg);
562 font:500 13px/1 var(--d-font);cursor:pointer;transition:transform 120ms cubic-bezier(.22,1,.36,1)}
563.fd-st-demo-all:active{transform:scale(.96)}
564.fd-st-demo a:focus-visible,.fd-st-demo button:focus-visible{outline:2px solid rgba(10,132,255,.7);outline-offset:2px;border-radius:9999px}
565.fd-st-demo-main{max-width:1280px;margin:0 auto;padding:clamp(24px,6vh,64px) clamp(16px,4vw,48px) 64px}
566.fd-st-demo-kick{display:flex;align-items:center;flex-wrap:wrap;gap:10px;margin:0 0 22px;font:500 13px/1 var(--d-mono);color:var(--d-mut);letter-spacing:.01em}
567.fd-st-demo-dot{display:block;width:8px;height:8px;border-radius:50%;background:var(--d-acc);box-shadow:0 0 0 4px rgba(224,70,27,.16)}
568.fd-st-demo-replay{appearance:none;-webkit-appearance:none;display:inline-flex;align-items:center;gap:6px;margin:0 0 0 auto;height:26px;padding:0 10px;border:0;border-radius:9999px;
569 background:rgba(16,16,20,.06);color:var(--d-mut);font:500 12px/1 var(--d-mono);letter-spacing:0;cursor:pointer;transition:background-color 120ms,color 120ms}
570.fd-st-demo-replay:hover{background:rgba(16,16,20,.1);color:var(--d-ink)}
571.fd-st-demo-replay svg{display:block}
572.fd-st-demo-h1{max-width:13ch;color:var(--d-ink);font:600 clamp(44px,8.4vw,112px)/.94 var(--d-disp);letter-spacing:-.045em}
573.fd-st-demo-lede{max-width:34em;margin:28px 0 0;color:var(--d-mut);font:400 clamp(17px,1.6vw,20px)/1.45 var(--d-font);letter-spacing:-.01em;text-wrap:pretty}
574.fd-st-demo-grid{display:grid;grid-template-columns:1fr 1.15fr 1.35fr;gap:16px;margin:clamp(40px,8vh,80px) 0 0}
575.fd-st-demo-card{display:flex;flex-direction:column;gap:14px;margin:0;padding:22px;border-radius:24px;background:#FBFAF7;box-shadow:0 0 0 1px var(--d-line);min-height:220px}
576.fd-st-demo-cardhead{display:flex;align-items:center;gap:10px;margin:0;font:500 12px/1 var(--d-mono);color:var(--d-mut);text-transform:uppercase;letter-spacing:.06em}
577.fd-st-demo-cardhead .fd-st-demo-replay{text-transform:none}
578.fd-st-demo-stat{margin:auto 0 0;color:var(--d-acc);font:600 clamp(56px,6vw,88px)/1 var(--d-disp);letter-spacing:-.05em;font-variant-numeric:tabular-nums}
579.fd-st-demo-note{margin:0;color:var(--d-mut);font:400 13px/1.45 var(--d-font);max-width:32em}
580.fd-st-demo-card--quote{background:var(--d-ink);color:#F3F1EC;box-shadow:none}
581.fd-st-demo-card--quote .fd-st-demo-cardhead,.fd-st-demo-card--quote .fd-st-demo-note{color:rgba(243,241,236,.6)}
582.fd-st-demo-card--quote .fd-st-demo-replay{background:rgba(255,255,255,.1);color:rgba(243,241,236,.7)}
583.fd-st-demo-quote{margin:auto 0 0;color:#F3F1EC;font:400 italic clamp(32px,3.2vw,44px)/1.05 var(--d-serif);letter-spacing:-.025em}
584.fd-st-demo-card--term{background:#16161B;box-shadow:0 0 0 1px rgba(255,255,255,.06),0 24px 48px -28px rgba(16,16,20,.6)}
585.fd-st-demo-card--term .fd-st-demo-cardhead{color:rgba(243,241,236,.5);text-transform:none;letter-spacing:0}
586.fd-st-demo-card--term .fd-st-demo-replay{background:rgba(255,255,255,.08);color:rgba(243,241,236,.7)}
587.fd-st-demo-term{display:flex;gap:12px;margin:auto 0 0;padding:16px;border-radius:12px;background:rgba(255,255,255,.04)}
588.fd-st-demo-prompt{margin:0;color:#7BE0A6;font:500 17px/1.45 var(--d-mono)}
589.fd-st-demo-typed{--fd-st-caret:#7BE0A6;color:#EDEBE6;font:400 15px/1.55 var(--d-mono);letter-spacing:0;text-wrap:pretty}
590.fd-st-demo-foot{margin:clamp(48px,10vh,96px) 0 0;padding:24px 0 0;border-top:1px solid var(--d-line)}
591.fd-st-demo-h2{margin:22px 0 0;max-width:22ch;color:var(--d-ink);font:500 clamp(32px,4.4vw,64px)/1.02 var(--d-disp);letter-spacing:-.035em}
592@media (max-width:900px){.fd-st-demo-grid{grid-template-columns:1fr}.fd-st-demo-nav{display:none}}
593`;

More Text Effects. Same file contract.

Text Morph

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

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.