A dark logistics hero whose monospace headline starts as a line of random letters, digits and symbols and resolves into the real sentence from left to right. After a short hold it scrambles again into the next of three headlines. A status line above shows a progress bar, the headline number and whether it is decoding or locked. Because the type is monospace and words never break, the layout does not jitter while characters change, and the heading reserves its height on every screen size.
import { useEffect, useRef, useState } from "react";
import { useInView, useReducedMotion } from "framer-motion";
// Headlines to decode, one after another. Keep every word under 12 characters for phones.
const headlines = ["Every parcel, tracked to the door.", "Every route, planned before sunrise.", "Every driver, paid the same day."];
const GLYPHS = "ABCDEFGHJKLMNPQRSTUVWXYZ0123456789#%&/<>+=*";
const DECODE_MS = 1250; // time to resolve a headline
const HOLD_MS = 1750; // time it stays readable before the next one
const FRAME_MS = 45; // how often unresolved characters change
const readouts = [
{ label: "Parcels today", value: "48,210" },
{ label: "On time", value: "99.2%" },
{ label: "Depots", value: "14" },
];
// A stable pseudo-random number per character, so each letter resolves at its own moment.
const seeded = (n: number): number => {
const x = Math.sin(n * 127.1 + 311.7) * 43758.5453;
return x - Math.floor(x);
};
export default function TextScrambleDecodeHeadline() {
const reduce = useReducedMotion();
const ref = useRef<HTMLElement>(null);
const inView = useInView(ref, { amount: 0.3 });
const [index, setIndex] = useState(0);
const [progress, setProgress] = useState(1);
const [tick, setTick] = useState(0);
useEffect(() => {
if (reduce || !inView) return;
let raf = 0;
let timer = 0;
let last = 0;
const start = performance.now();
const loop = (now: number) => {
const p = Math.min(1, (now - start) / DECODE_MS);
if (now - last >= FRAME_MS || p === 1) {
last = now;
setProgress(p);
setTick((t) => t + 1);
}
if (p < 1) raf = requestAnimationFrame(loop);
else timer = window.setTimeout(() => setIndex((i) => (i + 1) % headlines.length), HOLD_MS);
};
setProgress(0);
raf = requestAnimationFrame(loop);
return () => {
cancelAnimationFrame(raf);
window.clearTimeout(timer);
};
}, [index, reduce, inView]);
const text = headlines[index];
const done = progress >= 1;
let position = 0;
const words = text.split(" ").map((word) => {
const chars = [...word].map((ch) => {
const i = position++;
// Resolve roughly left to right, with some characters landing early or late.
const at = (i / text.length) * 0.72 + seeded(i + index * 53) * 0.26;
const resolved = progress >= at;
const glyph = resolved ? ch : GLYPHS[Math.floor(seeded(i * 7 + tick * 13) * GLYPHS.length)];
return { glyph, resolved };
});
position++;
return chars;
});
return (
<section ref={ref} className="relative overflow-hidden bg-slate-950 px-6 py-20 text-slate-100 sm:py-28">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0"
style={{
backgroundImage: "linear-gradient(rgba(56,189,248,0.08) 1px, transparent 1px), linear-gradient(90deg, rgba(56,189,248,0.08) 1px, transparent 1px)",
backgroundSize: "56px 56px",
maskImage: "radial-gradient(80% 70% at 20% 30%, black, transparent)",
WebkitMaskImage: "radial-gradient(80% 70% at 20% 30%, black, transparent)",
}}
/>
<div aria-hidden="true" className="pointer-events-none absolute -left-32 top-10 h-80 w-80 rounded-full bg-sky-500/20 blur-3xl" />
<div className="relative mx-auto max-w-6xl">
<div className="flex items-center gap-4 font-mono text-xs uppercase tracking-widest text-sky-300">
<span className="flex items-center gap-2">
<span className={`h-2 w-2 rounded-full ${done ? "bg-emerald-400" : "bg-sky-400"}`} />
Halden Freight
</span>
<span className="h-px w-16 overflow-hidden bg-sky-300/20 sm:w-32">
<span className="block h-full origin-left bg-sky-300" style={{ transform: `scaleX(${progress})` }} />
</span>
<span className="tabular-nums text-slate-400">
{String(index + 1).padStart(2, "0")}/{String(headlines.length).padStart(2, "0")} {done ? "locked" : "decoding"}
</span>
</div>
<h2 className="mt-8 min-h-[4.5em] font-mono text-3xl font-semibold leading-tight tracking-tight sm:min-h-[3.4em] sm:text-5xl lg:min-h-[2.4em] lg:text-7xl">
<span className="sr-only">{headlines.join(" ")}</span>
<span aria-hidden="true">
{words.map((chars, w) => (
<span key={w}>
<span className="inline-block whitespace-nowrap">
{chars.map((c, i) => (
<span key={i} className={c.resolved ? "text-white" : "text-sky-400/70"}>
{c.glyph}
</span>
))}
</span>{" "}
</span>
))}
</span>
</h2>
<div className="mt-10 grid gap-10 lg:grid-cols-2 lg:items-end">
<div>
<p className="max-w-md text-base leading-relaxed text-slate-400 sm:text-lg">
One dashboard for dispatch, live tracking and payouts. Your customers get a link, your drivers get paid, you get your evenings back.
</p>
<div className="mt-7 flex flex-wrap gap-3">
<a href="#" className="inline-flex h-12 items-center rounded-md bg-sky-400 px-6 text-sm font-semibold text-slate-950 transition hover:bg-sky-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-300 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950">
Book a demo
</a>
<a href="#" className="inline-flex h-12 items-center rounded-md border border-slate-700 px-6 text-sm font-semibold text-slate-200 transition hover:border-slate-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-300 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950">
Track a parcel
</a>
</div>
</div>
<dl className="grid grid-cols-3 divide-x divide-slate-800 border-y border-slate-800 font-mono">
{readouts.map((r) => (
<div key={r.label} className="px-4 py-4 first:pl-0">
<dt className="text-xs uppercase tracking-wider text-slate-400">{r.label}</dt>
<dd className="mt-1 text-xl font-semibold tabular-nums text-white sm:text-2xl">{r.value}</dd>
</div>
))}
</dl>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/text-scramble-decode-headline.json
A `requestAnimationFrame` loop tracks progress from 0 to 1 over `DECODE_MS`. Every character has its own threshold based on its position in the sentence plus a small fixed offset. While progress is below the threshold the character is drawn as a random glyph; once progress passes it, the real letter is shown and stays.
Each threshold is roughly 72 percent position and 26 percent pseudo-random, generated by a small seeded function. That keeps the overall direction left to right while avoiding a hard wipe. Raise the random share for a more chaotic decode.
In a proportional font a random W is wider than a random I, so the line would change width and re-wrap on every frame. With `font-mono` every glyph has the same width and the headline stays still while its characters change.
In the loop, remove the `setTimeout` that advances `index` when progress reaches 1. The headline will decode once when it scrolls into view and stay.
State updates are throttled to one every `FRAME_MS` (45ms), not every frame, and only a few dozen spans change. The loop is cancelled when the section leaves the screen and both the frame request and the timer are cleaned up on unmount.
Related: Typewriter Headline with Cycling Phrases, Rotating Word Headline with Spring Flip, Word by Word Blur In Paragraph Reveal.
More text effects or all free UI components. Start for free to have a full site built around it.
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.