# Text Scramble (Moonarc)

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.

- Import: `import TextScramble from '@moonarc/core/TextScramble'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/text-scramble.json`
- Tier B · category text · trigger scroll, hover, load
- Readout: `<TextScramble speed={30}>`
- Browser support: widely (every browser)
- Measured cost: 921 B raw JS · 530 B gzip · + runtime (with dependencies 2.6 kB raw; CSS 4.2 kB raw)
- Page: https://moonarc.dev/components/text-scramble/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `text` | `string` | none | Final text. |
| `as` | `HTMLTag` | `'span'` | Element to render. |
| `speed` | `number` | `30` | Milliseconds between one character locking and the next; 20–40 reads as decoding. |
| `charset` | `string` | `'!<>-_\\/[]{}—=+*^?#'` | Characters to scramble with. |
| `trigger` | `'view' | 'load' | 'hover'` | `'view'` | When to run. |
| `delay` | `number` | `0` | Delay before the first lock, ms. |

## Usage

```astro
<TextScramble as="h2" text="Decrypting…" />
<TextScramble text="hover me" trigger="hover" charset="01" />
```

## 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.

## Craft

- Locks left to right at a fixed interval, so a ten-character word resolves in 300 ms: long enough to read as decoding, short enough not to wait for.
- Spaces never scramble; word shapes stay readable throughout.
- A visually hidden span (.ma-sr) carries the final text; the churning span is aria-hidden, so assistive tech never hears the noise.
- Tabular figures while running so digits do not make the box breathe.

## Replaces

- DecryptedText (React Bits)
- HyperText (Magic UI)
- TextScramble (Aceternity)

## Source

```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>

```
