Scramble Text

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

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

Browser support · All evergreen browsers. Dim colour uses color-mix() with an opacity fallback.

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/scramble-text.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/text/scramble-text.tsx
1/**
2 * ScrambleText — a decoding effect. Characters cycle through a glyph set and
3 * lock in left to right, each after a short, slightly random number of
4 * cycles. Hover (or focus) re-scrambles. Spaces and punctuation you choose
5 * stay put so the shape of the line reads from the first frame.
6 *
7 * Props
8 * - 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 ghost
20 * 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 jitter
70 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 frame
101 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-deps
146 }, [replay]);
147
148 const onEnter = () => {
149 if (hover && !running.current) run();
150 };
151
152 return (
153 <Tag
154 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-readable
224 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`;

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 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.