Decode Text
Text that resolves out of noise, left to right, at a stepped 30 frames per second.
import { useEffect, useLayoutEffect, useRef, type CSSProperties } from 'react';
import { SPRING, Spring, createLoop, onReducedMotionChange, prefersReducedMotion } from '../shared/spring';
import { resolveAccent, type Accent } from '../shared/accent';
import { watchLive } from '../effects-kit/kit';
import '../shared/ember.css';
import './decode-text.css';
export interface DecodeTextProps {
text?: string;
/** mount: decode when it appears. hover: re-decode on pointer over. Changes to `text` always decode. */
trigger?: 'mount' | 'hover' | 'change';
/** Characters locked per second. */
speed?: number;
/** Noise alphabet. */
glyphs?: string;
/** Font size in px. */
size?: number;
/** Geist Mono when true; Geist with fixed per-character cells when false. */
mono?: boolean;
accent?: Accent;
className?: string;
style?: CSSProperties;
}
/** The noise is stepped, not smooth. */
const FPS = 30;
/** Steps of pure noise before the first character locks. */
const LEAD = 4;
interface Cell {
el: HTMLSpanElement;
final: string;
/** Scrambling and waiting for the sweep. */
queued: boolean;
/** Being removed: scrambles, then collapses when the sweep reaches it. */
dying: boolean;
width: Spring;
}
export function DecodeText({
text = 'Signal acquired.',
trigger = 'mount',
speed = 28,
glyphs = '01<>/\\[]{}=+*^?#',
size = 28,
mono = true,
accent: accentProp,
className,
style,
}: DecodeTextProps) {
const accent = resolveAccent(accentProp);
const rootRef = useRef<HTMLSpanElement>(null);
const rowRef = useRef<HTMLSpanElement>(null);
const probeRef = useRef<HTMLSpanElement>(null);
const props = useRef({ text, trigger, speed, glyphs });
props.current = { text, trigger, speed, glyphs };
const api = useRef<{ setText: (t: string) => void; remeasure: () => void } | null>(null);
useLayoutEffect(() => {
const root = rootRef.current!;
const row = rowRef.current!;
const probe = probeRef.current!;
let widths = new Map<string, number>();
let cells: Cell[] = [];
let locking: Cell[] = [];
let lockedThisSweep = 0;
let stepIndex = 0;
let clock = 0;
let reduced = prefersReducedMotion();
let live = true;
let font = '';
/** Advance width of one character, measured in the live font so cells match exactly. */
const widthOf = (ch: string) => {
if (!ch) return 0;
let w = widths.get(ch);
if (w === undefined) {
probe.textContent = ch;
w = probe.getBoundingClientRect().width;
widths.set(ch, w);
}
return w;
};
const readFont = () => {
const cs = getComputedStyle(row);
const next = `${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
if (next !== font) {
font = next;
widths = new Map();
}
};
const randomGlyph = () => {
const g = Array.from(props.current.glyphs || '#');
return g[Math.floor(Math.random() * g.length)];
};
const setState = (c: Cell, state: 'noise' | 'locking' | 'locked') => {
c.el.className = `em-decode__cell${state === 'locked' ? '' : ` is-${state}`}`;
};
const makeCell = (final: string, width: number): Cell => {
const el = document.createElement('span');
el.className = 'em-decode__cell';
el.textContent = final;
const c: Cell = { el, final, queued: false, dying: false, width: new Spring(width, SPRING.snappy) };
el.style.width = `${width}px`;
return c;
};
const enqueue = (c: Cell) => {
if (c.final === ' ' && !c.dying) {
c.queued = false;
c.el.textContent = ' ';
setState(c, 'locked');
return;
}
c.queued = true;
c.el.textContent = randomGlyph();
setState(c, 'noise');
};
const restartSweep = () => {
lockedThisSweep = 0;
stepIndex = 0;
clock = 0;
};
/** Instant: every cell shows its final character at its final width. */
const settleNow = () => {
cells = cells.filter((c) => {
if (c.dying) {
c.el.remove();
return false;
}
return true;
});
for (const c of cells) {
c.queued = false;
c.el.textContent = c.final;
c.width.set(widthOf(c.final));
c.el.style.width = `${c.width.value}px`;
setState(c, 'locked');
}
locking = [];
};
const build = (value: string, decode: boolean) => {
row.textContent = '';
readFont();
cells = Array.from(value).map((ch) => makeCell(ch, widthOf(ch)));
for (const c of cells) row.appendChild(c.el);
if (decode && !reduced) {
for (const c of cells) enqueue(c);
restartSweep();
loop.wake();
}
};
const setText = (value: string) => {
const chars = Array.from(value);
if (reduced) {
build(value, false);
return;
}
readFont();
chars.forEach((ch, i) => {
let c = cells[i];
if (!c) {
// New positions decode in from zero width.
c = makeCell(ch, 0);
c.width.target = widthOf(ch);
cells.push(c);
row.appendChild(c.el);
enqueue(c);
return;
}
if (c.dying) {
c.dying = false;
c.final = ch;
c.width.target = widthOf(ch);
enqueue(c);
return;
}
if (c.final !== ch) {
c.final = ch;
c.width.target = widthOf(ch);
enqueue(c);
}
});
for (let i = chars.length; i < cells.length; i++) {
const c = cells[i];
if (c.dying) continue;
// Removed positions scramble out, then collapse.
c.dying = true;
c.queued = true;
setState(c, 'noise');
}
restartSweep();
loop.wake();
};
const step = () => {
stepIndex++;
for (const c of locking) if (!c.dying) setState(c, 'locked');
locking = [];
const { speed } = props.current;
const due = Math.max(0, Math.floor(((stepIndex - LEAD) / FPS) * Math.max(0.1, speed)));
for (const c of cells) {
if (!c.queued) continue;
if (lockedThisSweep < due) {
lockedThisSweep++;
c.queued = false;
if (c.dying) {
c.el.textContent = '';
c.width.target = 0;
} else {
c.el.textContent = c.final;
setState(c, 'locking');
locking.push(c);
}
} else {
c.el.textContent = randomGlyph();
}
}
};
const tick = (dt: number) => {
if (!live) return false;
clock += dt;
let guard = 0;
while (clock >= 1 / FPS && guard++ < 4) {
clock -= 1 / FPS;
step();
}
if (guard >= 4) clock = 0;
let moving = false;
for (const c of cells) {
if (c.width.step(dt)) moving = true;
c.el.style.width = `${Math.max(0, c.width.value).toFixed(2)}px`;
}
// Collapsed cells leave the DOM.
cells = cells.filter((c) => {
if (c.dying && !c.queued && c.width.target === 0 && c.width.value <= 0.01) {
c.el.remove();
return false;
}
return true;
});
const pending = cells.some((c) => c.queued) || locking.length > 0;
return pending || moving;
};
const loop = createLoop(tick);
const remeasure = () => {
font = '';
readFont();
for (const c of cells) {
const w = c.dying && !c.queued ? 0 : widthOf(c.final);
if (c.width.value === c.width.target) c.width.set(w);
else c.width.target = w;
c.el.style.width = `${c.width.value}px`;
}
loop.wake();
};
api.current = { setText, remeasure };
const onEnter = (e: PointerEvent) => {
if (props.current.trigger !== 'hover' || reduced) return;
if (e.type === 'pointerenter' && e.pointerType === 'touch') return;
if (cells.some((c) => c.queued)) return;
for (const c of cells) if (!c.dying) enqueue(c);
restartSweep();
loop.wake();
};
root.addEventListener('pointerenter', onEnter);
root.addEventListener('pointerdown', onEnter);
const offLive = watchLive(root, (l) => {
live = l;
if (live) loop.wake();
});
const offReduced = onReducedMotionChange((r) => {
reduced = r;
if (r) {
loop.stop();
settleNow();
}
});
const onResize = () => remeasure();
window.addEventListener('resize', onResize);
let alive = true;
document.fonts?.ready.then(() => {
if (alive) remeasure();
});
build(props.current.text, props.current.trigger === 'mount');
return () => {
alive = false;
loop.stop();
offLive();
offReduced();
window.removeEventListener('resize', onResize);
root.removeEventListener('pointerenter', onEnter);
root.removeEventListener('pointerdown', onEnter);
};
}, []);
const first = useRef(true);
useLayoutEffect(() => {
if (first.current) {
first.current = false;
return;
}
api.current?.setText(text);
}, [text]);
useEffect(() => {
api.current?.remeasure();
}, [size, mono]);
return (
<span
ref={rootRef}
className={['ember', 'em-decode', mono ? 'em-decode--mono' : '', className].filter(Boolean).join(' ')}
data-accent={accent}
style={{ ...style, ['--em-decode-size' as string]: `${size}px` }}
>
<span className="em-sr">{text}</span>
<span ref={rowRef} className="em-decode__row" aria-hidden="true" />
<span ref={probeRef} className="em-decode__probe" aria-hidden="true" />
</span>
);
}
export default DecodeText;
Customize
mount
hover
change
012345678900123456789028
012345678900123456789028
Unlocked cells are faint noise, the cell locking this frame is accent, locked cells are bone. When the text changes only the characters that differ scramble again; extra positions decode in or scramble out and collapse while the width springs. Cells are fixed-width, so the line never jitters.
Install
npx shadcn@latest add https://obsydeui.com/r/decode-text.jsonUsage
Import it from where the CLI puts it, or copy the files below by hand.
import { DecodeText } from '@/components/obsyde/ember/decode-text/DecodeText';
export function Example() {
return (
<DecodeText trigger="mount" />
);
}
Files
Plain React, TypeScript and CSS. No dependencies. The download keeps these paths.
ember/decode-text/DecodeText.tsxcomponentember/decode-text/decode-text-demo.cssstylesember/decode-text/decode-text.cssstylesember/decode-text/DecodeTextDemo.tsxdemoember/effects-kit/kit.tssharedember/shared/accent.tssharedember/shared/spring.tssharedember/shared/ember.cssshared
API
| Prop | Type | Default |
|---|---|---|
accentAccent | 'ember' | 'volt' | 'arc' | 'ember' |
triggerTrigger | 'mount' | 'hover' | 'change' | 'mount' |
speedSpeed (chars/s) | number | 28 |
sizeSize (px) | number | 28 |
monoMono | boolean | true |
glyphsNoise glyphs | string | '01<>/\[]{}=+*^?#' |