Every piece of text on this site arrives by "decoding": random glyphs first, then real characters resolving left to right. This post covers the two layers of that language — a handwritten decode engine in the DOM and a shared glyph shader family in WebGL.
Why not an off-the-shelf plugin?
GSAP has ScrambleTextPlugin and it works well. But I needed two things it doesn't do: a ▌ frontier cursor sitting exactly at the typing boundary, and a meaningful reverse when scroll rewinds — characters re-scrambling in the opposite order they resolved. The engine's core is a single GSAP tween; on every tick it draws three regions from the progress value:
// [ solved | ▌ | scramble ]
const solved = text.slice(0, frontier);
const noise = randomGlyphs(text.length - frontier);
el.textContent = solved + CURSOR + noise;A lock against layout shift
Text width jitters during a scramble — W and i aren't the same width. The fix is cheap: measure the element's final width before the animation starts and pin it as min-width. The in-text shimmer stays (that's the effect), the layout shift is gone.
Accessibility starts at the screen reader
Scrambled text is noise to a screen reader. The real text lives in aria-label for the whole animation; the visual layer is aria-hidden. Under prefers-reduced-motion the engine never runs and text renders directly.
A concurrency budget
When a section becomes visible, five or six texts may want to decode at once. Allow them all and the screen turns into a carnival. The engine grants slots to at most four concurrent scrambles; the rest fall back to a calmer typing mode. The chaos stays controlled.
The WebGL side: one atlas, five consumers
The portrait, project previews, skill labels, backdrop and dust — they all draw glyphs. If each produced its own glyph texture, that's five canvases and five uploads. Instead a single runtime glyph atlas is shared through a refcounted singleton, and a common GLSL chunk is concatenated into every shader:
// glyph.chunk.glsl — every consumer uses the same functions
float glyphLuma(vec2 cell, float t);
vec4 glyphAt(vec2 uv, float index);Lessons
- Reverse playback must be designed up front: "reverse" isn't a feature you bolt on later, it's part of the data model.
- Measure, pin, then animate: without the layout lock, a scramble animation never looks professional.
- Shared resources want refcounting: the first consumer builds the atlas, the last one destroys it; everyone in between rides for free.