# Scramble Text

> Text that decodes into place from random glyphs.

Category: Text. Docs: https://neonkit.heyaadi.com/components/scramble-text

## Installation

```bash
npx shadcn@latest add https://neonkit.heyaadi.com/r/scramble-text.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/scramble-text`.

npm dependencies: none. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

## Usage

```tsx
import { ScrambleText } from "@/components/ui/scramble-text"

<ScrambleText text="ACCESS GRANTED" charset="symbols" />
```

## Examples

### Status line

Change text and it scrambles into the new value.

```tsx
"use client";

import * as React from "react";
import { ScrambleText } from "@/components/ui/scramble-text";

const statuses = ["Uplink established", "Decrypting payload", "Handshake verified", "Standing by"];

export default function ScrambleTextDemo() {
  const [index, setIndex] = React.useState(0);

  React.useEffect(() => {
    const timer = setInterval(() => setIndex((i) => (i + 1) % statuses.length), 3200);
    return () => clearInterval(timer);
  }, []);

  return (
    <div className="flex flex-col items-center gap-3 text-center">
      <ScrambleText
        as="p"
        aria-live="polite"
        text={statuses[index]}
        trigger="mount"
        duration={1100}
        className="font-mono text-[clamp(1.5rem,5vw,2.5rem)] font-medium tracking-[0.02em] text-white/90"
      />
      <p className="text-[13px] text-white/55">Relay 07, Kowloon uplink</p>
    </div>
  );
}
```

### On hover

Inside a link, hover and keyboard focus on the link both replay the decode.

```tsx
import { ScrambleText } from "@/components/ui/scramble-text";

const links = [
  { label: "Changelog", href: "#changelog" },
  { label: "GitHub", href: "#github" },
  { label: "Sponsor", href: "#sponsor" },
  { label: "Contact", href: "#contact" },
];

export default function ScrambleTextHover() {
  return (
    <nav aria-label="Footer" className="flex flex-wrap items-center justify-center gap-x-5 gap-y-3 sm:gap-x-8">
      {links.map((link) => (
        <a
          key={link.href}
          href={link.href}
          className="rounded-md font-mono text-sm text-white/60 outline-none transition-colors hover:text-white focus-visible:text-white focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon-accent)/0.6)] focus-visible:ring-offset-4 focus-visible:ring-offset-[#05060a]"
        >
          <ScrambleText text={link.label} trigger="hover" duration={500} charset="symbols" />
        </a>
      ))}
    </nav>
  );
}
```

### Charsets

Four presets, or pass your own string of glyphs.

```tsx
import { ScrambleText } from "@/components/ui/scramble-text";

const rows = [
  { charset: "upper", text: "NEON CITY 2049", note: "Upper" },
  { charset: "hex", text: "0x22D3EE", note: "Hex" },
  { charset: "binary", text: "10110010", note: "Binary" },
  { charset: "symbols", text: "Access granted", note: "Symbols" },
  { charset: "アイウエオカキクケコサシスセソ", text: "TOKYO", note: "Katakana" },
] as const;

export default function ScrambleTextCharsets() {
  return (
    <dl className="grid w-full max-w-md grid-cols-[auto_1fr] items-baseline gap-x-8 gap-y-4">
      {rows.map((row) => (
        <div key={row.note} className="contents">
          <dt className="text-[13px] text-white/55">{row.note}</dt>
          <dd className="font-mono text-xl text-white/90">
            <ScrambleText text={row.text} charset={row.charset} duration={1400} />
          </dd>
        </div>
      ))}
    </dl>
  );
}
```

## API reference

### ScrambleText

Also accepts native attributes for the element it renders.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text` | `string` | — | The text it decodes into. |
| `trigger` | `"mount" \| "hover" \| "inView"` | `"inView"` | When the decode runs. hover replays it each time the pointer enters the text, or the link or button around it, and on keyboard focus. |
| `duration` | `number` | `900` | Milliseconds from the start to the last character locking in. |
| `charset` | `"upper" \| "hex" \| "binary" \| "symbols" \| string` | `"upper"` | Glyphs it scrambles through. |
| `as` | `"span" \| "p" \| "div" \| "h1" \| "h2" \| "h3" \| "h4"` | `"span"` | Element to render. |
| `color` | `NeonColor` | `inherited` | Color of the unresolved glyphs and the lock-in flash. |

## Accessibility

- Screen readers get the real text once from a visually hidden copy; the scrambling cells are aria-hidden.
- Every character keeps the width of its final glyph while it scrambles, so nothing on the line shifts. Monospace faces decode most cleanly; in a proportional face a wide glyph can briefly overlap its neighbours.
- The decode runs on requestAnimationFrame and writes attributes directly, without React re-renders per frame.
- Under reduced motion the final text shows straight away.

## Source

`components/ui/scramble-text.tsx`

```tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

// Each character sits in a cell that keeps the width of its final glyph, so
// the random glyphs (drawn by ::after) never push the line around. Unresolved
// cells glow dim neon, the decode head burns bright, and a locked character
// cools from a flash back to the text color.
const css = `
.nk-scramble-text-c[data-s] { color: transparent; }
.nk-scramble-text-c[data-s]::after {
  content: attr(data-g);
  position: absolute; inset: 0;
  text-align: center;
  color: rgb(var(--neon) / 0.6);
}
.nk-scramble-text-c[data-s="h"]::after {
  color: color-mix(in srgb, rgb(var(--neon)) 40%, white);
  text-shadow: 0 0 0.1em rgb(var(--neon) / var(--neon-glow, 1)), 0 0 0.35em rgb(var(--neon) / calc(0.7 * var(--neon-glow, 1)));
}
.nk-scramble-text-c[data-s="l"] { color: inherit; animation: nk-scramble-text-lock 0.7s cubic-bezier(0.23, 1, 0.32, 1) both; }
.nk-scramble-text-c[data-s="l"]::after { content: none; }
@keyframes nk-scramble-text-lock {
  from { color: color-mix(in srgb, rgb(var(--neon)) 40%, white); text-shadow: 0 0 0.1em rgb(var(--neon) / var(--neon-glow, 1)), 0 0 0.4em rgb(var(--neon) / calc(0.6 * var(--neon-glow, 1))); }
}
@media (prefers-reduced-motion: reduce) { .nk-scramble-text-c[data-s="l"] { animation: none; } }
`;

const charsets = {
  upper: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
  hex: "0123456789ABCDEF",
  binary: "01",
  symbols: "!<>-_\\/[]{}=+*^?#%&@$",
} as const;

type Trigger = "mount" | "hover" | "inView";

const useIsoLayoutEffect = typeof window === "undefined" ? React.useEffect : React.useLayoutEffect;

// Lowercase cells get lowercase glyphs, so proportional faces overlap less.
function pick(glyphs: string, final = "") {
  const glyph = glyphs[Math.floor(Math.random() * glyphs.length)] ?? "";
  return final !== final.toUpperCase() ? glyph.toLowerCase() : glyph;
}

export interface ScrambleTextProps
  extends Omit<React.HTMLAttributes<HTMLElement>, "color" | "children"> {
  /** The text it decodes into. Changing it scrambles into the new text. */
  text: string;
  /** When the decode runs: on mount, on every hover (of the text or the link or button around it, keyboard focus included), or when it scrolls into view. */
  trigger?: Trigger;
  /** Milliseconds from the first glyph to the last one locking in. */
  duration?: number;
  /** Glyphs to scramble through: a preset or your own string. */
  charset?: keyof typeof charsets | (string & {});
  /** Element to render. */
  as?: "span" | "p" | "div" | "h1" | "h2" | "h3" | "h4";
  /** Palette name or any hex color. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
}

const ScrambleText = React.forwardRef<HTMLElement, ScrambleTextProps>(
  (
    {
      className,
      style,
      color,
      text,
      trigger = "inView",
      duration = 900,
      charset = "upper",
      as: Tag = "span",
      ...props
    },
    ref,
  ) => {
    const rootRef = React.useRef<HTMLElement>(null);
    const visualRef = React.useRef<HTMLSpanElement>(null);
    const frameRef = React.useRef(0);
    React.useImperativeHandle(ref, () => rootRef.current as HTMLElement);

    const glyphs = charset in charsets ? charsets[charset as keyof typeof charsets] : charset;

    const cells = React.useCallback(
      () => Array.from(visualRef.current?.querySelectorAll<HTMLElement>("[data-c]") ?? []),
      [],
    );

    const arm = React.useCallback(() => {
      for (const cell of cells()) {
        cell.setAttribute("data-s", "s");
        cell.setAttribute("data-g", pick(glyphs, cell.textContent ?? ""));
      }
    }, [cells, glyphs]);

    const play = React.useCallback(() => {
      cancelAnimationFrame(frameRef.current);
      const list = cells();
      const count = list.length;
      if (count === 0) return;
      const start = performance.now();
      const changed = new Array<number>(count).fill(0);
      const locked = new Array<boolean>(count).fill(false);
      arm();

      const frame = (now: number) => {
        const elapsed = now - start;
        let head = -1;
        for (let i = 0; i < count; i++) {
          if (locked[i]) continue;
          // Left to right: the first glyph locks at 30% of the run, the last at 100%.
          if (elapsed >= duration * (0.3 + (0.7 * (i + 1)) / count)) {
            locked[i] = true;
            list[i].setAttribute("data-s", "l");
            list[i].removeAttribute("data-g");
            continue;
          }
          if (head < 0) head = i;
          if (now - changed[i] > 50 + (i % 3) * 14) {
            changed[i] = now;
            list[i].setAttribute("data-g", pick(glyphs, list[i].textContent ?? ""));
          }
          list[i].setAttribute("data-s", i === head ? "h" : "s");
        }
        if (head >= 0) frameRef.current = requestAnimationFrame(frame);
      };
      frameRef.current = requestAnimationFrame(frame);
    }, [arm, cells, duration, glyphs]);

    const reduced = React.useCallback(
      () => typeof window !== "undefined" && !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches,
      [],
    );

    // Arm before paint so the final text doesn't flash ahead of the decode.
    useIsoLayoutEffect(() => {
      const list = cells();
      for (const cell of list) {
        cell.removeAttribute("data-s");
        cell.removeAttribute("data-g");
      }
      if (trigger === "hover" || reduced()) return;
      arm();
      const node = rootRef.current;
      if (trigger === "mount" || !node || typeof IntersectionObserver === "undefined") {
        play();
        return () => cancelAnimationFrame(frameRef.current);
      }
      const io = new IntersectionObserver(
        ([entry]) => {
          if (!entry.isIntersecting) return;
          io.disconnect();
          play();
        },
        { threshold: 0.4 },
      );
      io.observe(node);
      return () => {
        io.disconnect();
        cancelAnimationFrame(frameRef.current);
      };
    }, [text, trigger, arm, play, cells, reduced]);

    // Hover follows the link or button the text sits in, and so does keyboard focus.
    React.useEffect(() => {
      const root = rootRef.current;
      if (trigger !== "hover" || !root) return;
      const host = root.closest<HTMLElement>("a, button, [role='link'], [role='button']") ?? root;
      const onEnter = () => {
        if (!reduced()) play();
      };
      const onFocus = () => {
        let keyboard = true;
        try {
          keyboard = host.matches(":focus-visible");
        } catch {
          // Older engines without :focus-visible treat every focus as keyboard focus.
        }
        if (keyboard) onEnter();
      };
      host.addEventListener("pointerenter", onEnter);
      host.addEventListener("focus", onFocus);
      return () => {
        host.removeEventListener("pointerenter", onEnter);
        host.removeEventListener("focus", onFocus);
        cancelAnimationFrame(frameRef.current);
      };
    }, [trigger, play, reduced]);

    const words = text.split(/(\s+)/);

    return (
      <Tag
        ref={rootRef as React.Ref<HTMLHeadingElement>}
        style={neonVars(color, style)}
        className={cn("[--neon:var(--neon-accent,34_211_238)]", className)}
        {...props}
      >
        <style>{css}</style>
        <span className="sr-only">{text}</span>
        <span ref={visualRef} aria-hidden>
          {words.map((word, w) =>
            /^\s+$/.test(word) ? (
              word
            ) : word ? (
              <span key={w} className="inline-block whitespace-nowrap">
                {Array.from(word).map((char, c) => (
                  <span key={c} data-c="" className="nk-scramble-text-c relative inline-block">
                    {char}
                  </span>
                ))}
              </span>
            ) : null,
          )}
        </span>
      </Tag>
    );
  },
);
ScrambleText.displayName = "ScrambleText";

export { ScrambleText };
```
