Split Text
Split any string into chars, words or lines and reveal it with rise, wave, scatter, blur or a human-paced typewriter.
A decoding effect: characters cycle through glyphs and lock in left to right. Hover re-scrambles; layout never shifts.
Browser support · All evergreen browsers. Dim colour uses color-mix() with an opacity fallback.
npx shadcn@latest add https://freedesignmd.com/r/scramble-text.json1/**2 * ScrambleText — a decoding effect. Characters cycle through a glyph set and3 * lock in left to right, each after a short, slightly random number of4 * cycles. Hover (or focus) re-scrambles. Spaces and punctuation you choose5 * stay put so the shape of the line reads from the first frame.6 *7 * Props8 * - text: string Final text (also what screen readers get).9 * - as?: keyof JSX intrinsic Element. Default "span".10 * - glyphs?: string Scramble alphabet. Default A–Z 0–9 and symbols.11 * - speed?: number Lock-in speed in characters per second. Default 28.12 * - scramble?: number Cycles before the first char locks. Default 8.13 * - trigger?: "view" | "mount" | "manual". Default "view".14 * - hover?: boolean Re-scramble on hover / focus. Default true.15 * - replay?: number | string Change to replay.16 * - keep?: string Characters never scrambled. Default " .,:-/·".17 * CSS vars: --fd-sc-dim (colour of unresolved glyphs, default 50% currentColor).18 *19 * Layout stays stable: every char cell is sized by its final glyph (a ghost20 * layer holds the width), and scrambling glyphs are centred inside it.21 * For best results use a monospace or tabular font.22 * Accessibility: a visually hidden copy holds the final text; glyph cells are aria-hidden.23 * Reduced motion shows the final text with no scrambling.24 * Browser support: all evergreen browsers.25 */26import { useCallback, useEffect, useRef, useState } from "react";27import type { CSSProperties, ElementType } from "react";28 29export interface ScrambleTextProps {30 text: string;31 as?: ElementType;32 glyphs?: string;33 speed?: number;34 scramble?: number;35 trigger?: "view" | "mount" | "manual";36 hover?: boolean;37 replay?: number | string;38 keep?: string;39 className?: string;40 style?: CSSProperties;41}42 43const DEFAULT_GLYPHS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#$%&*+=<>/\\[]{}";44 45export function ScrambleText({46 text,47 as: Tag = "span",48 glyphs = DEFAULT_GLYPHS,49 speed = 28,50 scramble = 8,51 trigger = "view",52 hover = true,53 replay,54 keep = " .,:-/·",55 className,56 style,57}: ScrambleTextProps) {58 const root = useRef<HTMLElement>(null);59 const raf = useRef(0);60 const running = useRef(false);61 62 const run = useCallback(() => {63 const el = root.current;64 if (!el) return;65 if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return;66 const cells = Array.from(el.querySelectorAll<HTMLElement>(".fd-sc-live"));67 const chars = Array.from(text);68 const n = chars.length;69 // each char locks at a time: left-to-right sweep + a bit of jitter70 const per = 1000 / speed;71 const lead = (scramble * 1000) / 30;72 const lockAt = chars.map((_, i) => lead + i * per + Math.random() * per * 2.5);73 const nextSwap = new Float32Array(n);74 const locked = new Uint8Array(n);75 const t0 = performance.now();76 cancelAnimationFrame(raf.current);77 running.current = true;78 el.setAttribute("data-scrambling", "");79 cells.forEach((c, i) => {80 if (keep.includes(chars[i])) {81 locked[i] = 1;82 return;83 }84 c.textContent = glyphs[(Math.random() * glyphs.length) | 0];85 c.setAttribute("data-s", "");86 });87 const tick = (t: number) => {88 const e = t - t0;89 let left = 0;90 for (let i = 0; i < n; i++) {91 if (locked[i]) continue;92 const c = cells[i];93 if (e >= lockAt[i]) {94 locked[i] = 1;95 c.textContent = chars[i];96 c.removeAttribute("data-s");97 continue;98 }99 left++;100 // glyphs flicker at ~30 fps with per-cell phase, not every frame101 if (e >= nextSwap[i]) {102 c.textContent = glyphs[(Math.random() * glyphs.length) | 0];103 nextSwap[i] = e + 26 + Math.random() * 40;104 }105 }106 if (left > 0) raf.current = requestAnimationFrame(tick);107 else {108 running.current = false;109 el.removeAttribute("data-scrambling");110 }111 };112 raf.current = requestAnimationFrame(tick);113 }, [text, glyphs, speed, scramble, keep]);114 115 useEffect(() => {116 const el = root.current;117 if (!el) return;118 if (trigger === "mount") run();119 let io: IntersectionObserver | null = null;120 if (trigger === "view") {121 io = new IntersectionObserver(122 (es) => {123 if (es.some((x) => x.isIntersecting)) {124 io?.disconnect();125 run();126 }127 },128 { threshold: 0.4 },129 );130 io.observe(el);131 }132 return () => {133 io?.disconnect();134 cancelAnimationFrame(raf.current);135 };136 }, [trigger, run]);137 138 const first = useRef(true);139 useEffect(() => {140 if (first.current) {141 first.current = false;142 return;143 }144 run();145 // eslint-disable-next-line react-hooks/exhaustive-deps146 }, [replay]);147 148 const onEnter = () => {149 if (hover && !running.current) run();150 };151 152 return (153 <Tag154 ref={root}155 className={`fd-sc${className ? " " + className : ""}`}156 style={style}157 onPointerEnter={onEnter}158 onFocus={onEnter}159 >160 <style>{css}</style>161 <span className="fd-sc-sr">{text}</span>162 {Array.from(text).map((ch, i) => (163 <span key={i} className="fd-sc-cell" aria-hidden="true">164 <span className="fd-sc-ghost">{ch}</span>165 <span className="fd-sc-live">{ch}</span>166 </span>167 ))}168 </Tag>169 );170}171 172const css = `173.fd-sc,.fd-sc *{box-sizing:border-box}174.fd-sc{--fd-sc-dim:color-mix(in srgb,currentColor 50%,transparent);margin:0;padding:0;font-variant-numeric:tabular-nums}175.fd-sc-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}176.fd-sc-cell{position:relative;display:inline-block;margin:0;padding:0;white-space:pre;vertical-align:baseline}177.fd-sc-ghost{visibility:hidden;margin:0;padding:0}178.fd-sc-live{position:absolute;left:50%;top:0;margin:0;padding:0;transform:translateX(-50%);white-space:pre}179.fd-sc-live[data-s]{color:var(--fd-sc-dim)}180@supports not (color:color-mix(in srgb,red,blue)){.fd-sc-live[data-s]{opacity:.5}}181`;182 183/* ───────────────────────────── demo ───────────────────────────── */184 185const LOG = [186 { t: "09:41:07", k: "BLOCK", m: "Credential stuffing · 1,284 req/min from AS 49981", c: "#FF6B6B" },187 { t: "09:41:12", k: "ISOLATE", m: "Host fin-ws-0217 · beacon to 185.220.101.4", c: "#FFB547" },188 { t: "09:41:19", k: "PATCH", m: "CVE-2026-31337 · OpenSSH 9.8p1 on 42 nodes", c: "#5BE3A8" },189 { t: "09:41:23", k: "VERIFY", m: "Token rotation complete · 0 sessions orphaned", c: "#7AA7FF" },190];191 192export function Demo() {193 const [n, setN] = useState(0);194 return (195 <div className="fd-sc-demo">196 <style>{demoCss}</style>197 <div className="fd-sc-demo-grid" aria-hidden="true" />198 <header className="fd-sc-demo-bar">199 <span className="fd-sc-demo-brand">200 <svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2.5l8 3v6.2c0 4.7-3.3 8.4-8 9.8-4.7-1.4-8-5.1-8-9.8V5.5z" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" /><path d="M8.5 12.2l2.3 2.3 4.7-4.8" fill="none" stroke="#5BE3A8" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /></svg>201 <ScrambleText text="SENTRYLINE" trigger="mount" speed={18} />202 </span>203 <nav className="fd-sc-demo-nav" aria-label="Primary">204 {["Platform", "Threat intel", "Customers", "Docs"].map((l) => (205 <a key={l} href={`#${l.toLowerCase().replace(/\s/g, "-")}`}>206 <ScrambleText text={l} trigger="manual" speed={60} scramble={4} />207 </a>208 ))}209 </nav>210 <span className="fd-sc-demo-status"><span aria-hidden="true" />All systems nominal</span>211 </header>212 213 <main className="fd-sc-demo-main">214 <p className="fd-sc-demo-eyebrow">215 <ScrambleText text="// AUTONOMOUS SOC · v4.2" trigger="mount" speed={40} />216 </p>217 <h1 className="fd-sc-demo-h1">218 <ScrambleText text="Breaches decoded" trigger="mount" speed={16} scramble={10} replay={n} />219 <br />220 <span className="fd-sc-demo-h1b"><ScrambleText text="before they land." trigger="mount" speed={18} scramble={18} replay={n} /></span>221 </h1>222 <p className="fd-sc-demo-sub">223 Sentryline correlates 2.3 billion events a day across endpoint, identity and cloud, and contains threats in a median 38 seconds — with a human-readable224 reason for every action.225 </p>226 <div className="fd-sc-demo-cta">227 <a className="fd-sc-demo-btn" href="#trial"><ScrambleText text="Start 14-day trial" trigger="manual" speed={50} scramble={5} /></a>228 <button type="button" className="fd-sc-demo-btn fd-sc-demo-btn--ghost" onClick={() => setN((v) => v + 1)}>229 <ScrambleText text="Re-run decrypt" trigger="manual" speed={50} scramble={5} />230 </button>231 </div>232 233 <section className="fd-sc-demo-panel" aria-label="Live response log">234 <div className="fd-sc-demo-panelhead">235 <span>response.log</span>236 <span>tail -f · eu-central-1</span>237 </div>238 <ol className="fd-sc-demo-log">239 {LOG.map((l, i) => (240 <li key={l.t}>241 <span className="fd-sc-demo-time">{l.t}</span>242 <span className="fd-sc-demo-kind" style={{ color: l.c }}>243 <ScrambleText text={l.k.padEnd(7, " ")} speed={20} scramble={10 + i * 6} replay={n} />244 </span>245 <ScrambleText className="fd-sc-demo-msg" text={l.m} speed={70} scramble={14 + i * 8} replay={n} />246 </li>247 ))}248 </ol>249 </section>250 </main>251 </div>252 );253}254 255const demoCss = `256.fd-sc-demo,.fd-sc-demo *,.fd-sc-demo *::before,.fd-sc-demo *::after{box-sizing:border-box}257.fd-sc-demo{--d-mono:ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;--d-font:ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;258 --d-ink:#E8EEF2;--d-mut:#8693A0;--d-line:rgba(232,238,242,.1);--d-acc:#5BE3A8;259 position:relative;overflow:hidden;margin:0;padding:0;min-height:100vh;background:#07090C;color:var(--d-ink);font:400 15px/1.5 var(--d-font);-webkit-font-smoothing:antialiased}260.fd-sc-demo-grid{position:absolute;inset:0;pointer-events:none;261 background-image:linear-gradient(rgba(91,227,168,.05) 1px,transparent 1px),linear-gradient(90deg,rgba(91,227,168,.05) 1px,transparent 1px);background-size:48px 48px;262 mask-image:radial-gradient(70% 60% at 50% 30%,#000,transparent);-webkit-mask-image:radial-gradient(70% 60% at 50% 30%,#000,transparent)}263.fd-sc-demo-bar{position:relative;display:flex;align-items:center;justify-content:space-between;gap:16px;margin:0;padding:18px clamp(16px,4vw,48px);border-bottom:1px solid var(--d-line)}264.fd-sc-demo-brand{display:inline-flex;align-items:center;gap:10px;margin:0;font:600 15px/1 var(--d-mono);letter-spacing:.12em}265.fd-sc-demo-brand svg{display:block}266.fd-sc-demo-nav{display:flex;gap:28px;margin:0}267.fd-sc-demo-nav a{margin:0;padding:6px 0;color:var(--d-mut);font:500 13px/1 var(--d-mono);text-decoration:none;text-transform:uppercase;letter-spacing:.06em}268.fd-sc-demo-nav a:hover{color:var(--d-ink)}269.fd-sc-demo-status{display:inline-flex;align-items:center;gap:8px;margin:0;color:var(--d-mut);font:500 12px/1 var(--d-mono)}270.fd-sc-demo-status span{display:block;width:7px;height:7px;border-radius:50%;background:var(--d-acc);box-shadow:0 0 12px var(--d-acc)}271.fd-sc-demo-main{position:relative;max-width:1200px;margin:0 auto;padding:clamp(40px,9vh,96px) clamp(16px,4vw,48px) 48px}272.fd-sc-demo-eyebrow{margin:0 0 24px;color:var(--d-acc);font:500 13px/1 var(--d-mono);letter-spacing:.08em}273.fd-sc-demo-h1{margin:0;color:var(--d-ink);font:600 clamp(40px,7vw,92px)/1 var(--d-mono);letter-spacing:-.05em}274.fd-sc-demo-h1b{color:var(--d-acc)}275.fd-sc-demo-sub{max-width:36em;margin:28px 0 0;color:var(--d-mut);font:400 17px/1.55 var(--d-font);letter-spacing:-.01em;text-wrap:pretty}276.fd-sc-demo-cta{display:flex;flex-wrap:wrap;gap:12px;margin:32px 0 0}277.fd-sc-demo-btn{appearance:none;-webkit-appearance:none;display:inline-flex;align-items:center;height:46px;margin:0;padding:0 22px;border:0;border-radius:9999px;background:var(--d-acc);color:#04120B;278 font:600 14px/1 var(--d-mono);text-decoration:none;cursor:pointer;letter-spacing:.02em;transition:transform 120ms cubic-bezier(.22,1,.36,1)}279.fd-sc-demo-btn:active{transform:scale(.96)}280.fd-sc-demo-btn--ghost{background:transparent;color:var(--d-ink);box-shadow:inset 0 0 0 1px rgba(232,238,242,.24)}281.fd-sc-demo a:focus-visible,.fd-sc-demo button:focus-visible{outline:2px solid rgba(10,132,255,.8);outline-offset:2px}282.fd-sc-demo-panel{margin:clamp(40px,8vh,72px) 0 0;border-radius:20px;background:rgba(232,238,242,.03);box-shadow:inset 0 0 0 1px var(--d-line);overflow:hidden}283.fd-sc-demo-panelhead{display:flex;justify-content:space-between;gap:12px;margin:0;padding:12px 18px;border-bottom:1px solid var(--d-line);color:var(--d-mut);font:500 12px/1 var(--d-mono)}284.fd-sc-demo-log{margin:0;padding:10px 0;list-style:none}285.fd-sc-demo-log li{display:grid;grid-template-columns:88px 92px 1fr;gap:12px;margin:0;padding:9px 18px;font:400 13px/1.4 var(--d-mono);color:var(--d-ink)}286.fd-sc-demo-time{color:var(--d-mut)}287.fd-sc-demo-kind{font-weight:600}288@media (max-width:820px){.fd-sc-demo-nav{display:none}.fd-sc-demo-log li{grid-template-columns:72px 1fr}.fd-sc-demo-msg{grid-column:1/-1;color:var(--d-mut)}}289@media (max-width:480px){.fd-sc-demo-status{display:none}}290`;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 headline whose letters drift away from — or toward — your cursor and spring home, each on its own spring.