Skip to content
Black FridayBlack Friday launch price: Lifetime $149 until 30 Nov. Regular price $299 from 1 Dec.ends in 15d 03:13:31Get Lifetime →
loopkit
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.