Scramble Decode
A headline that decrypts itself: on entering view each character cycles through random glyphs before locking to its final letter, resolving left-to-right. Unsettled glyphs glow. Reduced-motion and no-JS visitors just see the finished text.
- IntersectionObserver
- requestAnimationFrame
- per-char stagger
Source
A single self-contained .astro component. It uses Prism's design tokens (--spectrum-*, --ink…) with sensible fallbacks, so it renders even outside this site.
---
export const meta = {
title: "Scramble Decode",
tags: ["text", "decode", "matrix"],
description:
"A headline that decrypts itself: on entering view each character cycles through random glyphs before locking to its final letter, resolving left-to-right. Unsettled glyphs glow. Reduced-motion and no-JS visitors just see the finished text.",
tech: ["IntersectionObserver", "requestAnimationFrame", "per-char stagger"],
interactive: true,
height: 360,
};
---
<section class="scr" data-scramble>
<div class="scr__inner">
<p class="scr__eyebrow">// establishing link</p>
<h2 class="scr__title" data-decode data-final="Decoding the signal">
Decoding the signal
</h2>
<p class="scr__sub" data-decode data-final="stream stabilised — 100% integrity" data-delay="380">
stream stabilised — 100% integrity
</p>
</div>
</section>
<style>
.scr {
display: grid;
place-items: center;
min-height: 360px;
padding: clamp(2rem, 6vw, 4rem) clamp(1.25rem, 5vw, 3rem);
background:
radial-gradient(120% 90% at 50% -10%, color-mix(in oklab, var(--spectrum-1, #8b5cf6) 20%, transparent), transparent 60%),
var(--bg, #0a0b12);
color: var(--ink, #f2f2f7);
text-align: center;
overflow: hidden;
}
.scr__inner { max-width: 40rem; display: grid; gap: 0.9rem; justify-items: center; }
.scr__eyebrow {
margin: 0;
font-family: var(--font-mono, monospace);
font-size: var(--step--1, 0.85rem);
letter-spacing: 0.12em;
color: var(--spectrum-3, #45d3e8);
opacity: 0.85;
}
.scr__title {
margin: 0;
font-family: var(--font-mono, "JetBrains Mono", monospace);
font-size: var(--step-5, 2.8rem);
line-height: 1.08;
letter-spacing: -0.01em;
font-weight: 700;
text-wrap: balance;
/* keep width stable while glyphs swap */
font-variant-ligatures: none;
}
.scr__sub {
margin: 0;
font-family: var(--font-mono, monospace);
font-size: var(--step-1, 1.25rem);
color: var(--ink-muted, #b7b9c9);
text-wrap: balance;
}
</style>
<script>
(function () {
const GLYPHS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!<>-_\\/[]{}=+*^?#§%&@";
function decode(el: HTMLElement, delay: number) {
const text = el.dataset.final || el.textContent || "";
// Build one span per character so we can colour unsettled glyphs.
el.textContent = "";
const spans = Array.from(text).map((ch) => {
const s = document.createElement("span");
if (ch === " ") {
s.textContent = " ";
s.dataset.space = "1";
}
el.appendChild(s);
return s;
});
const perChar = 3; // frames each char stays scrambled after its turn
const stagger = 1.6; // frames between characters starting to settle
const start = performance.now() + delay;
let raf = 0;
function frame(now: number) {
const f = Math.max(0, (now - start) / 16.7);
let done = true;
spans.forEach((s, i) => {
if (s.dataset.space) return;
const settleAt = i * stagger + perChar;
if (f >= settleAt) {
if (s.textContent !== text[i]) {
s.textContent = text[i];
s.style.color = "";
s.style.textShadow = "";
}
} else if (f >= i * stagger - perChar) {
s.textContent = GLYPHS[(Math.random() * GLYPHS.length) | 0];
s.style.color = "var(--spectrum-3, #45d3e8)";
s.style.textShadow = "0 0 12px color-mix(in oklab, var(--spectrum-3, #45d3e8) 60%, transparent)";
done = false;
} else {
// not yet its turn — hold a faint placeholder
s.textContent = GLYPHS[(Math.random() * GLYPHS.length) | 0];
s.style.color = "var(--ink-faint, #8a8c9e)";
done = false;
}
});
if (!done) raf = requestAnimationFrame(frame);
}
raf = requestAnimationFrame(frame);
}
function bind() {
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
document.querySelectorAll<HTMLElement>("[data-scramble]").forEach((root) => {
if (root.dataset.bound) return;
root.dataset.bound = "1";
const targets = Array.from(
root.querySelectorAll<HTMLElement>("[data-decode]")
);
if (reduce) return; // leave final text as authored
// Pre-scramble so on-screen elements don't flash their answer first.
targets.forEach((t) => {
const text = t.dataset.final || t.textContent || "";
t.dataset.final = text;
t.textContent = "";
Array.from(text).forEach((ch) => {
const s = document.createElement("span");
if (ch === " ") { s.textContent = " "; s.dataset.space = "1"; }
else {
s.textContent = GLYPHS[(Math.random() * GLYPHS.length) | 0];
s.style.color = "var(--ink-faint, #8a8c9e)";
}
t.appendChild(s);
});
});
const io = new IntersectionObserver(
(entries, obs) => {
entries.forEach((e) => {
if (!e.isIntersecting) return;
obs.disconnect();
targets.forEach((t) =>
decode(t, parseInt(t.dataset.delay || "0", 10))
);
});
},
{ threshold: 0.4 }
);
io.observe(root);
});
}
document.addEventListener("astro:page-load", bind);
if (document.readyState !== "loading") bind();
})();
</script>