freeNew143 lines · framer-motion
Text Scramble
Noise resolves into the headline you meant. A full-width section where the headline decodes itself out of random characters, left to right, then (optionally) re-scrambles on a slow loop so the section never goes still.
- Use it for
- A section divider or statement line on a landing page that wants a terminal/decrypt feel without a video or canvas asset.
- Themes
- Light + dark
- License
- Commercial use
Live
Decrypted on load
Every pixel, deliberately placed
A headline that resolves itself out of noise, then holds. Set it loose again on a loop, or let it settle once and for all.
tsx
"use client";
import * as React from "react";
import { useReducedMotion } from "framer-motion";
import { cn } from "@/lib/utils";
function useReducedMotionSafe() {
const prefersReduced = useReducedMotion();
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => setMounted(true), []);
return mounted ? !!prefersReduced : false;
}
/**
* Full-width kinetic text section: a headline that decodes itself out of random
* characters, left to right, then (optionally) re-scrambles on a slow loop.
*/
export interface TextScrambleProps {
eyebrow?: string;
headline?: string;
subcopy?: string;
/** ms between scramble frames. Lower = faster. */
speed?: number;
/** Re-run the scramble every few seconds instead of settling once. */
loop?: boolean;
accent?: "signal" | "info" | "lime" | "ok";
className?: string;
}
const CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ!<>-_\\/[]{}=+*^?#%01";
const accentText: Record<string, string> = { signal: "text-signal", info: "text-info", lime: "text-lime", ok: "text-ok" };
function randChar() {
return CHARS[Math.floor(Math.random() * CHARS.length)];
}
function useScramble(text: string, speed: number, loop: boolean, active: boolean) {
const [display, setDisplay] = React.useState(text);
React.useEffect(() => {
if (!active) {
setDisplay(text);
return;
}
let cancelled = false;
let rafId = 0;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const run = () => {
const queue = text.split("").map((ch, i) => {
const settle = i * 1.4 + 10 + Math.random() * 14;
return { to: ch, settle: ch === " " ? 0 : settle };
});
let frame = 0;
let last = performance.now();
const step = (now: number) => {
if (cancelled) return;
if (now - last < speed) {
rafId = requestAnimationFrame(step);
return;
}
last = now;
frame++;
let out = "";
let done = 0;
for (const q of queue) {
if (q.to === " ") {
out += " ";
done++;
} else if (frame >= q.settle) {
out += q.to;
done++;
} else {
out += randChar();
}
}
setDisplay(out);
if (done < queue.length) {
rafId = requestAnimationFrame(step);
} else if (loop) {
timeoutId = setTimeout(() => {
if (!cancelled) run();
}, 2800);
}
};
rafId = requestAnimationFrame(step);
};
run();
return () => {
cancelled = true;
cancelAnimationFrame(rafId);
if (timeoutId) clearTimeout(timeoutId);
};
}, [text, speed, loop, active]);
return display;
}
export function TextScramble({
eyebrow = "Decrypted on load",
headline = "Every pixel, deliberately placed",
subcopy = "A headline that resolves itself out of noise, then holds. Set it loose again on a loop, or let it settle once and for all.",
speed = 40,
loop = true,
accent = "signal",
className,
}: TextScrambleProps) {
const reduce = useReducedMotionSafe();
const hostRef = React.useRef<HTMLDivElement>(null);
const [visible, setVisible] = React.useState(false);
React.useEffect(() => {
const el = hostRef.current;
if (!el) return;
const io = new IntersectionObserver(([e]) => setVisible(e.isIntersecting), { threshold: 0.15 });
io.observe(el);
return () => io.disconnect();
}, []);
const display = useScramble(headline, speed, loop, visible && !reduce);
return (
<section ref={hostRef} className={cn("relative w-full overflow-hidden bg-background px-4 py-24 sm:px-6 lg:px-10", className)}>
<div
aria-hidden
className="absolute inset-0 [background-image:linear-gradient(var(--border)_1px,transparent_1px),linear-gradient(90deg,var(--border)_1px,transparent_1px)] [background-size:36px_36px] opacity-[0.3] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_40%,black,transparent_75%)]"
/>
<div className="relative mx-auto max-w-4xl text-center">
<p className="font-mono text-[11px] uppercase tracking-[0.22em] text-muted-foreground">{eyebrow}</p>
<h2 className="mt-4 whitespace-pre-wrap break-words font-display text-[clamp(2.1rem,6vw,4.25rem)] font-extrabold leading-[1.02] tracking-[-0.03em]">
<span className={cn(accentText[accent])}>{display}</span>
</h2>
<p className="mx-auto mt-5 max-w-xl text-base leading-relaxed text-muted-foreground">{subcopy}</p>
</div>
</section>
);
}
export default TextScramble;
How it behaves
- →Each character gets its own settle frame; unsettled characters cycle through a symbol set every animation frame via requestAnimationFrame.
- →Starts once the section is at least 15% in the viewport (IntersectionObserver), so it never burns cycles off-screen.
- →When loop is on, the finished headline waits briefly then re-scrambles itself from scratch.
- →Spaces in the headline are never scrambled, so word boundaries stay readable throughout.
- →prefers-reduced-motion renders the final headline immediately, no scrambling.
Accessibility
- ✓The decorative grid backdrop is purely visual; only the heading and body copy are meaningful content.
- ✓The animated text is a single <h2>; screen readers get the final string once JS settles it, same as the static fallback.
- ✓Honours prefers-reduced-motion by skipping the scramble entirely.
- ✓Honours prefers-reduced-motion.