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.

text/scramble.astro
---
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>