Calm Letter Decode Scramble

A headline that arrives as a row of soft marks (dots, dashes, tildes) and settles into real letters one by one from the left, finishing with a slow amber caret. The monospace face keeps every slot the same width, and a hidden copy of the finished line holds the box size, so nothing below it moves while the letters resolve. It suits archive, data and research sites where a decoding feel fits the subject.

Code

import { useEffect, useRef, useState } from "react";
import { useReducedMotion } from "framer-motion";
import { RotateCcw } from "lucide-react";

// The sentence that decodes, and the quiet characters it passes through.
const HEADLINE = "Every coastline, decoded by hand.";
const CALM = "ยท:-=~+/.";
const STEP_MS = 34; // delay between one letter settling and the next
const FLICKER_MS = 70; // how often an unsettled slot changes character
const HOLD_MS = 5200; // how long the finished line rests before decoding again

// Word boundaries, so a scrambled "-" or "/" can never open a new line break.
const WORDS = HEADLINE.split(" ").reduce<{ start: number; len: number }[]>((acc, word) => {
  const prev = acc[acc.length - 1];
  return [...acc, { start: prev ? prev.start + prev.len + 1 : 0, len: word.length }];
}, []);

type Slot = { ch: string; done: boolean };

const settled = (): Slot[] => [...HEADLINE].map((ch) => ({ ch, done: true }));
const pick = () => CALM[Math.floor(Math.random() * CALM.length)];

export default function CalmLetterDecodeScrambleTextReveal() {
  const reduce = useReducedMotion();
  const rootRef = useRef<HTMLElement>(null);
  const [slots, setSlots] = useState<Slot[]>(() =>
    [...HEADLINE].map((ch) => ({ ch: ch === " " ? " " : pick(), done: ch === " " })),
  );
  const [run, setRun] = useState(0);
  const [visible, setVisible] = useState(true);

  useEffect(() => {
    const el = rootRef.current;
    if (!el) return;
    const io = new IntersectionObserver(([e]) => setVisible(e.isIntersecting));
    io.observe(el);
    return () => io.disconnect();
  }, []);

  useEffect(() => {
    if (reduce) {
      setSlots(settled());
      return;
    }
    if (!visible) return;
    let start = performance.now();
    const timer = window.setInterval(() => {
      const t = performance.now() - start;
      const end = 160 + HEADLINE.length * STEP_MS;
      if (t > end + HOLD_MS) start = performance.now();
      setSlots(
        [...HEADLINE].map((ch, i) => {
          const done = ch === " " || t > 160 + i * STEP_MS;
          return { ch: done ? ch : pick(), done };
        }),
      );
    }, FLICKER_MS);
    return () => window.clearInterval(timer);
  }, [reduce, visible, run]);

  return (
    <section ref={rootRef} className="lds-root relative overflow-hidden px-5 py-20 sm:px-10 sm:py-28">
      <style>{`
        .lds-root { background: radial-gradient(120% 90% at 20% 0%, #16302f 0%, #0d1a1c 55%, #0a1315 100%); color: #efe6d2; }
        .lds-grain { position: absolute; inset: 0; opacity: .5; pointer-events: none;
          background-image: radial-gradient(rgba(239,230,210,.05) 1px, transparent 1px); background-size: 3px 3px; }
        .lds-stack { display: grid; }
        .lds-stack > * { grid-area: 1 / 1; }
        .lds-line { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-weight: 600;
          font-size: clamp(1.7rem, 5.4vw, 3.6rem); line-height: 1.15; letter-spacing: -.01em; }
        .lds-ghost { visibility: hidden; }
        .lds-ch { transition: color .35s ease, opacity .35s ease; }
        .lds-ch[data-done="false"] { color: #7fa39c; opacity: .55; }
        .lds-ch[data-done="true"] { color: #efe6d2; }
        @keyframes lds-caret { 50% { opacity: 0 } }
        .lds-caret { display: inline-block; width: .5ch; height: .9em; margin-left: .15ch; vertical-align: -.08em;
          background: #d9a45b; animation: lds-caret 1.1s steps(1) infinite; }
        @media (prefers-reduced-motion: reduce) { .lds-caret { animation: none; } .lds-ch { transition: none; } }
      `}</style>
      <div className="lds-grain" aria-hidden="true" />
      <div className="relative mx-auto max-w-4xl">
        <p className="text-sm font-medium uppercase tracking-[0.2em] text-[#d9a45b]">Tidewater Map Archive</p>
        <h1 className="lds-line mt-5">
          <span className="sr-only">{HEADLINE}</span>
          <span className="lds-stack" aria-hidden="true">
            <span className="lds-ghost">{HEADLINE}<span className="lds-caret" /></span>
            <span>
              {WORDS.map(({ start, len }, w) => (
                <span key={w}>
                  {w > 0 && " "}
                  <span className="whitespace-nowrap">
                    {slots.slice(start, start + len).map((s, i) => (
                      <span key={i} className="lds-ch" data-done={s.done}>{s.ch}</span>
                    ))}
                  </span>
                </span>
              ))}
              <span className="lds-caret" />
            </span>
          </span>
        </h1>
        <p className="mt-6 max-w-xl text-base leading-relaxed text-[#c9c2b0] sm:text-lg">
          Four hundred hand-drawn charts of the northern shore, scanned at full size and transcribed line by line.
        </p>
        <div className="mt-9 flex flex-wrap items-center gap-3">
          <a href="#" className="inline-flex min-h-[44px] items-center rounded-full bg-[#efe6d2] px-6 text-sm font-semibold text-[#0d1a1c] transition hover:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#d9a45b] focus-visible:ring-offset-2 focus-visible:ring-offset-[#0d1a1c]">
            Browse the charts
          </a>
          <button
            type="button"
            onClick={() => setRun((n) => n + 1)}
            className="inline-flex min-h-[44px] items-center gap-2 rounded-full px-4 text-sm font-medium text-[#c9c2b0] transition hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#d9a45b]"
          >
            <RotateCcw aria-hidden="true" className="h-4 w-4" /> Replay
          </button>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/calm-letter-decode-scramble-text-reveal.json

When to use it

How to customize it

Accessibility

Questions

How does a text scramble effect work?

Each letter slot shows a random placeholder until its turn comes, then shows the real letter. A timer redraws the unsettled slots every 70 milliseconds, and a slot settles once the elapsed time passes its index multiplied by a fixed step.

Why does my scramble make the text jump around?

Placeholder characters are usually a different width from the letters they stand in for, and some, like hyphens, let the line break early. A monospace font fixes the widths, grouping each word in a no-wrap span stops early breaks, and a hidden copy of the final text holds the height.

Can I run it only once instead of on a loop?

Yes. In the plain version, clear the interval when the elapsed time passes the end of the sweep instead of resetting `start`. In React, stop the interval the same way and keep the Replay button for anyone who wants to see it again.

Does it work with a proportional font?

It works, but slots will change width as characters swap, so the line shimmers sideways. If you want a proportional face, give each letter span a fixed `inline-block` width measured from the final letter, or keep the effect on a short label.

Is it expensive to run?

No. It updates the text of about thirty small spans fourteen times a second while decoding, and the timer pauses when the section is off screen or motion is reduced.

Related: 112 CSS Text Effects and Hover Animations, CSS Animated Background Set: Gradient Mesh, Dot Grid Spotlight, Aurora and Grain.

More text effects or all free UI components. Start for free to have a full site built around it.

More in Text Effects

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap