Skip to content

Components / Text

Text Scramble

Random characters resolve into the real text from left to right when it scrolls into view, on load, or on hover. The server HTML is the final string; a small requestAnimationFrame loop does the rest and never runs under reduced motion.

Live demo

Decrypting the catalogue

hover to decode

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

921 B raw

530 B gzip.

Uses the shared runtime (1.9 kB raw, once per site). With those included: 2.6 kB raw.

CSS 4.2 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · widely available

every browser.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import TextScramble from '@moonarc/core/TextScramble';
---
<TextScramble as="h2" text="Decrypting…" />
<TextScramble text="hover me" trigger="hover" charset="01" />
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the shared runtime and the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/text-scramble.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy runtime.ts.

TextScramble.astro
---
/**
 * TextScramble — random characters resolve into the text, left to right.
 *
 * The server HTML is the final string. On trigger, a small rAF loop swaps
 * each character for a random one until that character's lock time, then
 * writes the real one. Bound through the runtime; never runs under reduced
 * motion, so the text is simply there.
 */
import type { HTMLAttributes, HTMLTag } from 'astro/types';

interface Props extends HTMLAttributes<'span'> {
  /** Final text. */
  text: string;
  /** Element to render. */
  as?: HTMLTag;
  /** Milliseconds between one character locking and the next. */
  speed?: number;
  /** Characters to scramble with. */
  charset?: string;
  /** When to run. */
  trigger?: 'view' | 'load' | 'hover';
  /** Delay before the first lock, ms. */
  delay?: number;
}

const { text, as: Tag = 'span', speed = 30, charset = '!<>-_\\/[]{}—=+*^?#', trigger = 'view', delay = 0, class: className, ...rest } = Astro.props;
---

<Tag class:list={['ma-scramble', className]} data-ma-scramble data-speed={speed} data-charset={charset} data-trigger={trigger} data-delay={delay} {...rest}>
  <span class="ma-scramble__text" aria-hidden="true">{text}</span>
  <span class="ma-sr">{text}</span>
</Tag>

<style is:global>
  @layer components {
    :where(.ma-scramble__text) {
      font-variant-ligatures: none;
    }
    :where(.ma-scramble[data-running] .ma-scramble__text) {
      /* keep the box stable while glyph widths churn */
      font-variant-numeric: tabular-nums;
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-scramble__text) {
        /* the script never binds; the final text is already here */
        opacity: 1;
      }
    }
  }
</style>

<script>
  import { onMount } from '../lib/runtime';

  onMount<HTMLElement>('[data-ma-scramble]', (el, { signal, reducedMotion }) => {
    if (reducedMotion) return;
    const node = el.querySelector<HTMLElement>('.ma-scramble__text');
    if (!node) return;
    const final = node.textContent ?? '';
    const chars = [...final];
    const speed = Number(el.dataset.speed ?? 30);
    const set = el.dataset.charset ?? '!<>-_\\/[]{}';
    const delay = Number(el.dataset.delay ?? 0);
    let raf = 0;
    let start = 0;
    const frame = (now: number) => {
      let out = '';
      let done = true;
      chars.forEach((c, i) => {
        if (c === ' ' || now >= start + i * speed) out += c;
        else {
          done = false;
          out += set[Math.floor(Math.random() * set.length)];
        }
      });
      node.textContent = out;
      if (done) delete el.dataset.running;
      else raf = requestAnimationFrame(frame);
    };
    const run = () => {
      cancelAnimationFrame(raf);
      start = performance.now() + delay;
      el.dataset.running = '';
      raf = requestAnimationFrame(frame);
    };
    // Torn down mid-scramble (an element persisted across a swap), it runs to the end at once: every character
    // locked, the real text back. The next bind reads its final string from the node, and read the noise instead.
    signal.addEventListener('abort', () => {
      cancelAnimationFrame(raf);
      frame(Infinity);
    });
    const trigger = el.dataset.trigger;
    if (trigger === 'hover') el.addEventListener('pointerenter', run, { signal });
    else if (trigger === 'load') run();
    else {
      const io = new IntersectionObserver(([e]) => {
        if (e!.isIntersecting) {
          run();
          io.disconnect();
        }
      }, { threshold: 0.4 });
      io.observe(el);
      signal.addEventListener('abort', () => io.disconnect());
    }
  });
</script>

Props

PropTypeDefaultDescription
textstringnoneFinal text.
asHTMLTag'span'Element to render.
speednumber30Milliseconds between one character locking and the next; 20–40 reads as decoding.
charsetstring'!<>-_\\/[]{}—=+*^?#'Characters to scramble with.
trigger'view' | 'load' | 'hover''view'When to run.
delaynumber0Delay before the first lock, ms.

Reduced motion

The script does not bind; the final text is shown.

With ClientRouter

Bound through the shared runtime; the loop is cancelled before a swap and the real text put back, so an element persisted mid-scramble never keeps the noise.

Why it is built this way

Replaces: DecryptedText (React Bits) · HyperText (Magic UI) · TextScramble (Aceternity). See the migration table.

Splits a string into words, characters or lines on the server and reveals them with a capped stagger when they scroll into view.

every browserscroll

Types a string one character at a time in any font, with a caret that stays solid while typing and blinks once idle.

Chrome 116 · not Firefox · Safari 18load

A wave of boldness rolls through the letters of a line, each character animating its font weight on its own delay.

every browseralways

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown