Skip to content

Scramble Text

A decoding headline: every character resolves left to right out of random glyphs inside a fixed cell, so the line never changes width. Runs on hover, focus, tap or when it scrolls into view, with a glowing decode head and a lock flash.

Canvas 2D0 deps1 file · 9 KB TSXreduced-motion aware

Freebrutalistneon
Preview

Props

30
No runtime dependencies
"use client";

/*
 * ScrambleText: a decoding headline.
 *
 * Every character is its own fixed cell, sized by the REAL character, so the
 * line never changes width while it decodes, in monospace or in a proportional
 * face. Cells resolve left to right with a small deterministic stagger. The
 * character that is about to lock carries a glowing "head"; a cell that has
 * just locked flashes the accent and settles into the text colour. Runs on
 * hover, keyboard focus and tap, and (trigger="mount") once when it scrolls
 * into view. Reduced motion swaps the scramble for a soft fade.
 */

import { useEffect, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";

const CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*";
const HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
const EASE = "cubic-bezier(0.2, 0.7, 0.2, 1)";

/** 0 resolved, 1 scrambling, 2 scrambling and next to lock (the head), 3 locked this tick. */
type Phase = 0 | 1 | 2 | 3;

interface Frame {
  text: string;
  glyphs: string[];
  /** Horizontal squeeze that fits each glyph to its cell (proportional faces only). */
  fit: number[];
  phase: Phase[];
}

/** The tick each character locks on: a lead of scrambles, a stagger that tightens for long lines, a little fixed jitter. */
function lockTicks(chars: string[]): number[] {
  const step = Math.min(3, 54 / Math.max(chars.length, 1));
  return chars.map((c, i) => (c.trim() === "" ? 0 : Math.round(4 + i * step + ((i * 7) % 3) * 0.6)));
}

function cellStyle(p: Phase, accent: string): CSSProperties {
  if (p >= 2) {
    return { color: accent, textShadow: "0 0 " + (p === 2 ? 14 : 18) + "px " + accent, transition: "none" };
  }
  const t = "color 520ms " + EASE + ", text-shadow 520ms " + EASE;
  return { color: "inherit", textShadow: "0 0 0 transparent", transition: t };
}

export function ScrambleText({
  text,
  speed = 30,
  trigger = "hover",
  font = "mono",
  glyphs = CHARS,
  accent = "#8ab4ff",
  delay = 0,
  as = "button",
  replayKey = 0,
  className,
}: {
  text: string;
  /** ms between scramble frames. Lower is faster and busier. */
  speed?: number;
  /** "hover" decodes on hover, focus and tap. "mount" also decodes once when it scrolls into view. */
  trigger?: "hover" | "mount";
  /** "mono" locks the face to monospace; "sans" inherits the surrounding font. Width never jumps in either. */
  font?: "mono" | "sans";
  /** The characters the scramble draws from. */
  glyphs?: string;
  /** Colour of the decoding head and the lock flash (hex). */
  accent?: string;
  /** ms to wait before a mount decode starts. Handy for staggering lines. */
  delay?: number;
  /** "span" renders non-interactive text, e.g. inside a button. Drive it with replayKey. */
  as?: "button" | "span";
  /** Change this number to replay the decode from outside. */
  replayKey?: number;
  /** Replaces the default look (size, weight, tracking). */
  className?: string;
}) {
  const rootRef = useRef<HTMLElement | null>(null);
  const runRef = useRef<(soft?: boolean) => void>(() => {});
  const lastKey = useRef(replayKey);
  const [frame, setFrame] = useState<Frame | null>(null);
  const tint = HEX.test(accent) ? accent : "#8ab4ff";

  useEffect(() => {
    const chars = Array.from(text);
    const pool = Array.from(glyphs.trim() ? glyphs : CHARS);
    const locks = lockTicks(chars);
    const last = Math.max(0, ...locks);
    const ms = Math.max(10, speed);
    let timer: ReturnType<typeof setTimeout> | undefined;
    let wait: ReturnType<typeof setTimeout> | undefined;
    let io: IntersectionObserver | undefined;
    let running = false;
    const widths = new Map<string, number>();
    let ctx: CanvasRenderingContext2D | null = null;
    // Proportional faces: measure glyphs so a wide one is squeezed into a narrow cell.
    const measure = (c: string) => {
      let w = widths.get(c);
      if (w === undefined) {
        w = ctx ? ctx.measureText(c).width : 0;
        widths.set(c, w);
      }
      return w;
    };

    // A timer rather than requestAnimationFrame: the tick RATE is the effect,
    // and rAF would tie it to the display's refresh rate.
    const run = (soft?: boolean) => {
      if (soft && running) return;
      clearTimeout(timer);
      if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
        rootRef.current?.animate?.([{ opacity: 0.4 }, { opacity: 1 }], { duration: 320, easing: "ease-out" });
        return;
      }
      running = true;
      ctx = null;
      const el = rootRef.current;
      if (font === "sans" && el) {
        const cs = getComputedStyle(el);
        ctx = document.createElement("canvas").getContext("2d");
        if (ctx) ctx.font = cs.fontStyle + " " + cs.fontWeight + " " + cs.fontSize + " " + cs.fontFamily;
        widths.clear();
      }
      let t = 0;
      const tick = () => {
        if (t > last + 1) {
          running = false;
          setFrame(null);
          return;
        }
        let head = -1;
        const phase: Phase[] = [];
        const out: string[] = [];
        const fit: number[] = [];
        chars.forEach((c, i) => {
          const lock = locks[i] ?? 0;
          if (c.trim() === "" || lock < t) {
            phase.push(0);
            out.push(c);
            fit.push(1);
          } else if (lock === t) {
            phase.push(3);
            out.push(c);
            fit.push(1);
          } else {
            if (head < 0) head = i;
            phase.push(head === i ? 2 : 1);
            const g = pool[Math.floor(Math.random() * pool.length)] ?? "#";
            out.push(g);
            const gw = ctx ? measure(g) : 0;
            fit.push(gw > 0 ? Math.min(1, (measure(c) * 1.1) / gw) : 1);
          }
        });
        setFrame({ text, glyphs: out, fit, phase });
        t += 1;
        timer = setTimeout(tick, ms);
      };
      timer = setTimeout(tick, 0);
    };
    runRef.current = run;

    if (trigger === "mount") {
      const go = () => {
        wait = setTimeout(() => run(), Math.max(0, delay));
      };
      const el = rootRef.current;
      if (el && typeof IntersectionObserver !== "undefined") {
        io = new IntersectionObserver(
          (entries) => {
            if (entries.some((e) => e.isIntersecting)) {
              io?.disconnect();
              go();
            }
          },
          { threshold: 0.4 },
        );
        io.observe(el);
      } else {
        go();
      }
    }

    return () => {
      clearTimeout(timer);
      clearTimeout(wait);
      io?.disconnect();
      running = false;
    };
  }, [text, speed, trigger, glyphs, delay, font]);

  useEffect(() => {
    if (lastKey.current === replayKey) return;
    lastKey.current = replayKey;
    runRef.current();
  }, [replayKey]);

  const live = frame && frame.text === text ? frame : null;
  const nodes: ReactNode[] = [];
  let at = 0;
  text.split(" ").forEach((word, wi) => {
    if (wi > 0) {
      nodes.push(" ");
      at += 1;
    }
    const letters = Array.from(word);
    if (letters.length === 0) return;
    nodes.push(
      <span key={"w" + at} className="inline-block whitespace-nowrap">
        {letters.map((ch, k) => {
          const i = at + k;
          const p: Phase = live?.phase[i] ?? 0;
          const scrambling = p === 1 || p === 2;
          return (
            <span key={i} className="relative inline-block" style={cellStyle(p, tint)}>
              <span style={{ opacity: scrambling ? 0 : 1 }}>{ch}</span>
              {scrambling && (
                <span
                  className="absolute inset-0 flex items-center justify-center whitespace-pre"
                  style={{ opacity: p === 2 ? 1 : 0.42, transform: "scaleX(" + (live?.fit[i] ?? 1) + ")" }}
                >
                  {live?.glyphs[i]}
                </span>
              )}
            </span>
          );
        })}
      </span>,
    );
    at += letters.length;
  });

  const look = className ?? "text-2xl font-bold tracking-tight";
  const cls = (font === "mono" ? "font-mono " : "") + look;
  const inner = (
    <>
      <span className="sr-only">{text}</span>
      <span aria-hidden="true">{nodes}</span>
    </>
  );
  const setRef = (el: HTMLElement | null) => {
    rootRef.current = el;
  };
  const onEnter = (e: ReactPointerEvent<HTMLElement>) => {
    if (e.pointerType !== "touch") runRef.current();
  };

  if (as === "span") {
    return (
      <span ref={setRef} className={cls} onPointerEnter={onEnter}>
        {inner}
      </span>
    );
  }
  return (
    <button
      ref={setRef}
      type="button"
      aria-label={text}
      className={
        "cursor-pointer touch-manipulation rounded-md bg-transparent p-0 text-left select-none focus-visible:!outline-2 focus-visible:!outline-offset-4 focus-visible:!outline-[#8ab4ff] " +
        cls
      }
      onPointerEnter={onEnter}
      onFocus={() => runRef.current(true)}
      onClick={() => runRef.current(true)}
    >
      {inner}
    </button>
  );
}

Free. Pro unlocks everything, from $27/month.

Details

About this text effect

A headline that decodes. Each character is its own inline cell, sized by the REAL character and overlaid with the random glyph, so the line keeps its exact width and wrapping in a proportional face as well as in monospace. Cells resolve left to right on a fixed schedule: a lead of four scrambles, then one lock every three ticks (tightened for long lines so a sentence still finishes in about two seconds), with a small fixed jitter so the order reads as organic rather than a typewriter. The next character to lock carries a glowing accent head; a cell that has just locked flashes the accent and eases back to the text colour over 520ms. The tick is a setTimeout every `speed` milliseconds rather than requestAnimationFrame, so the rate is the same on every display. It runs on hover, keyboard focus and click or tap (touch gets the tap, since hover never fires there), and `trigger="mount"` also decodes once when the text scrolls into view, optionally after `delay` ms. Replay it from outside by changing `replayKey`, and render it as `as="span"` to put a decoding label inside your own button. Props: `text`, `speed`, `trigger`, `font` (mono or sans), `glyphs`, `accent`, `delay`, `as`, `replayKey` and `className`. React only, no dependencies.

Curator’s note

Rebuilt in October around one rule: the line must not move. Every character sits in a cell sized by the real letter, so it decodes in place in monospace or sans.

Pairs well with

Glitch Text Hover

text effects

Typewriter Text

text effects

Extruded Type Tilt

text effects