Skip to the content

Library/ 02 text-scrambleText

Text Scramble

Headlines that decode themselves into place.

Scroll inside the preview

Settings

900
trigger
once

What it does

Text Scramble is a text animation ready for your AI assistant, React or plain JavaScript, with no dependencies. Also called decrypt text, glyph reveal or hacker text.

Each letter flickers through random glyphs before settling, the text resolving roughly left to right. Plays when the line scrolls into view or on hover. Readers using reduced motion see the final text straight away.

Documentation

One copy gives an assistant everything it needs: the complete code, every option and every pitfall. Paste it in Claude, Cursor or ChatGPT and say where the effect goes; it adapts the integration to your stack, not the code.

  1. Copy the prompt

  2. Ask for it

    For instance: Add this text scramble effect to the hero of my home page.
    What it sends
    Markdown
    # Foundry44 · Text Scramble
    
    Text resolves character by character from random glyphs.
    
    Add this component to my project. Pick the setup below that matches my project and follow only that one. When it uses the code at the end, keep its logic as written (it is self-contained, with no dependencies) and adapt only the integration: JavaScript instead of TypeScript if the project has none, file names, imports, how it starts.
    
    ## Markup it expects
    
    The element marked `data-f44="text-scramble"`, with this structure inside. Texts, images and the number of items are the demo's: use mine. The `data-f44-*` attributes set options for the ready-made script only; with the code, pass the same options to `textScramble()` or as props.
    
    ```html
    <p data-f44="text-scramble">Journal · Issue 14 · October</p>
    ```
    
    ## Options
    
    | Option | Attribute | Type | Default | Description |
    |---|---|---|---|---|
    | `duration` | `data-f44-duration` | `number` | `900` | Time in ms for the whole text to resolve. |
    | `trigger` | `data-f44-trigger` | `"view" | "hover"` | `view` | What starts the effect: entering the viewport, or pointer hover / keyboard focus. |
    | `once` | `data-f44-once` | `boolean` | `true` | With `trigger: "view"`, play only the first time the element enters the viewport. |
    | `characters` | `data-f44-characters` | `string` | `ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#%&*+=<>/` | Glyphs shown while a character is still scrambling. |
    
    ## Setup
    
    - **Plain HTML, no build step**: don't paste the code below. Load the ready-made script once, at the end of `<body>`: `<script src="https://foundry44.dev/demos/text-scramble/text-scramble.js" defer></script>` (or download that file and serve it with the page). It starts itself on every `data-f44="text-scramble"` element and reads the `data-f44-*` attributes above.
    - **React / Next.js with shadcn/ui** (the project has a `components.json`; otherwise, see the next line): `npx shadcn@latest add https://foundry44.dev/r/text-scramble.json` writes `lib/text-scramble-core.ts` and `components/ui/text-scramble.tsx`, then use `<TextScramble />` with the options as props.
    - **React / Next.js from npm** (no shadcn/ui, or the user prefers a package): `npm install @foundry44/react` (or the project's package manager), then `import { TextScramble } from "@foundry44/react"` with the options as props. With a bundler and no React: `@foundry44/core`, then `import { textScramble } from "@foundry44/core/text-scramble"`.
    - **React / Next.js by hand**: save the code below as `lib/text-scramble-core.ts` and import it from there (`@/lib/text-scramble-core` where that alias exists). Don't name it `text-scramble.ts` next to a `TextScramble.tsx`: on macOS and Windows the two names collide. In a component marked `"use client"`, call `textScramble(ref.current, options)` in a `useEffect` and return `instance.destroy` as its cleanup; call `instance.update(options)` when options change.
    - **Any other stack**: create on mount with `textScramble(element, options)`, `update(options)` when they change, `destroy()` on unmount.
    
    Only the ready-made script finds `data-f44` elements and starts them by itself. Pasted code does not: call `textScramble(element)` on each element.
    
    ## Pitfalls
    
    - Respects prefers-reduced-motion: keep that behaviour.
    - With a proportional font the line width jitters while scrambling: use a monospace font, or keep the effect on short, single-line text.
    - Inline markup is preserved: only text nodes are rewritten, so links and <em> stay in place.
    - Do not change the text while it is scrambling: the change is overwritten when the effect ends. Changes made at rest are picked up on the next play.
    
    ## Code: `text-scramble-core.ts`
    
    ```ts
    // Copyright (c) 2026 Foundry44 · MIT License (SPDX-License-Identifier: MIT)
    /**
     * Foundry44 — text-scramble
     * Text resolves character by character from random glyphs, roughly left to
     * right, when it enters the viewport or when it is hovered.
     *
     * Self-contained: no imports, so this file can be copied as-is.
     */
    
    export interface TextScrambleOptions {
      /** Time in ms for the whole text to resolve. @range 200 4000 50 */
      duration?: number;
      /** What starts the effect: entering the viewport, or pointer hover / keyboard focus. */
      trigger?: "view" | "hover";
      /** With `trigger: "view"`, play only the first time the element enters the viewport. */
      once?: boolean;
      /** Glyphs shown while a character is still scrambling. */
      characters?: string;
    }
    
    export interface TextScrambleInstance {
      update(options: TextScrambleOptions): void;
      destroy(): void;
    }
    
    export const textScrambleDefaults: Required<TextScrambleOptions> = {
      duration: 900,
      trigger: "view",
      once: true,
      characters: "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#%&*+=<>/",
    };
    
    // Swapping glyphs every frame reads as flicker; ~20 swaps per second reads as scrambling.
    const GLYPH_INTERVAL = 50;
    // Share of the duration over which the reveal sweeps left to right; the rest is random
    // jitter so characters don't settle in a rigid line.
    const REVEAL_SWEEP = 0.7;
    
    export function textScramble(el: HTMLElement, options: TextScrambleOptions = {}): TextScrambleInstance {
      let opts = { ...textScrambleDefaults, ...options };
    
      const noop: TextScrambleInstance = { update() {}, destroy() {} };
      if (typeof window === "undefined") return noop;
      // The resting text is the final text, so reduced motion simply never scrambles.
      const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
      if (reduced) return noop;
    
      // Each text node with its original characters (Array.from keeps emoji whole).
      let segments: { node: Text; chars: string[] }[] = [];
      let revealAt: number[] = [];
      let frame = 0;
      let startTime = -1;
      let lastGlyphTime = Number.NEGATIVE_INFINITY;
      let played = false;
      let observer: IntersectionObserver | null = null;
    
      // Read the text at play time: at rest the DOM is the truth, even if a framework changed it.
      // Text nodes are scrambled one by one so inline markup (links, <em>) survives.
      const collectText = () => {
        segments = [];
        const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
        for (let node = walker.nextNode(); node; node = walker.nextNode()) {
          segments.push({ node: node as Text, chars: Array.from((node as Text).data) });
        }
      };
    
      const restore = () => {
        for (const { node, chars } of segments) node.data = chars.join("");
      };
    
      const randomGlyph = (original: string) => {
        const pool = Array.from(opts.characters || textScrambleDefaults.characters);
        const glyph = pool[Math.floor(Math.random() * pool.length)] ?? original;
        const isLower = original !== original.toUpperCase();
        return isLower ? glyph.toLowerCase() : glyph;
      };
    
      const render = (progress: number) => {
        let index = 0;
        for (const { node, chars } of segments) {
          let text = "";
          for (const char of chars) {
            text += progress >= (revealAt[index] ?? 0) || /\s/.test(char) ? char : randomGlyph(char);
            index++;
          }
          node.data = text;
        }
      };
    
      const tick = (now: number) => {
        if (startTime < 0) startTime = now;
        const progress = opts.duration > 0 ? (now - startTime) / opts.duration : 1;
        if (progress >= 1) {
          restore();
          frame = 0;
          return;
        }
        if (now - lastGlyphTime >= GLYPH_INTERVAL) {
          lastGlyphTime = now;
          render(progress);
        }
        frame = requestAnimationFrame(tick);
      };
    
      const play = () => {
        if (frame) return;
        played = true;
        collectText();
        const count = segments.reduce((sum, segment) => sum + segment.chars.length, 0);
        revealAt = Array.from(
          { length: count },
          (_, i) => (count > 1 ? i / (count - 1) : 0) * REVEAL_SWEEP + Math.random() * (1 - REVEAL_SWEEP),
        );
        startTime = -1;
        lastGlyphTime = Number.NEGATIVE_INFINITY;
        frame = requestAnimationFrame(tick);
      };
    
      const bind = () => {
        if (opts.trigger === "hover") {
          el.addEventListener("pointerenter", play);
          el.addEventListener("focusin", play);
          return;
        }
        if (opts.once && played) return;
        if (typeof IntersectionObserver === "undefined") {
          play();
          return;
        }
        observer = new IntersectionObserver(
          (entries) => {
            if (!entries.some((entry) => entry.isIntersecting)) return;
            play();
            if (opts.once) unbind();
          },
          // Wait until the text is a little inside the viewport so the start is actually seen.
          { rootMargin: "0px 0px -10% 0px" },
        );
        observer.observe(el);
      };
    
      const unbind = () => {
        el.removeEventListener("pointerenter", play);
        el.removeEventListener("focusin", play);
        observer?.disconnect();
        observer = null;
      };
    
      bind();
    
      return {
        update(next) {
          const previous = opts;
          opts = { ...opts, ...next };
          if (opts.trigger !== previous.trigger || opts.once !== previous.once) {
            unbind();
            bind();
          }
        },
        destroy() {
          unbind();
          if (frame) {
            cancelAnimationFrame(frame);
            frame = 0;
            restore();
          }
        },
      };
    }
    ```