Web InnoventixFreeCode

Scramble Text Effect

Original · free

scramble/decode text

byWeb InnoventixReact + Tailwind
txfxscrambletext-effects
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/txfx-scramble.json
txfx-scramble.tsx
"use client";

import {
  useCallback,
  useEffect,
  useRef,
  useState,
  type KeyboardEvent,
} from "react";
import { useReducedMotion } from "motion/react";

type GlyphSet = "glyphs" | "binary" | "katakana";
type CellState = "idle" | "run" | "done";
type Cell = { ch: string; state: CellState };
type QueueItem = { from: string; to: string; start: number; end: number; char: string };
type Option = { value: string; label: string };

const GLYPHS: Record<GlyphSet, string> = {
  glyphs: "!<>-_\\/[]{}=+*^?#%$&~|.:",
  binary: "01",
  katakana: "アイウエオカキクケコサシスセソタチツテトナニヌネ0123456789",
};

const MESSAGES = [
  { label: "Arrival", text: "Interfaces that decode on arrival" },
  { label: "Deliberate", text: "Every keystroke feels deliberate" },
  { label: "Attention", text: "Motion tuned to human attention" },
  { label: "Accessible", text: "Accessible by construction, not bolted on" },
] as const;

const GLYPH_OPTIONS: readonly Option[] = [
  { value: "glyphs", label: "Glyphs" },
  { value: "binary", label: "Binary" },
  { value: "katakana", label: "Katakana" },
];

const FOCUS_RING =
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900";

function useScramble(reduced: boolean, glyphSet: GlyphSet, speed: number) {
  const [cells, setCells] = useState<Cell[]>([]);
  const rafRef = useRef<number | null>(null);
  const frameRef = useRef(0);
  const queueRef = useRef<QueueItem[]>([]);
  const resolveRef = useRef<(() => void) | null>(null);
  const textRef = useRef("");
  const glyphRef = useRef(glyphSet);
  const speedRef = useRef(speed);
  const reducedRef = useRef(reduced);

  useEffect(() => {
    glyphRef.current = glyphSet;
  }, [glyphSet]);
  useEffect(() => {
    speedRef.current = speed;
  }, [speed]);
  useEffect(() => {
    reducedRef.current = reduced;
  }, [reduced]);

  const tick = useCallback(() => {
    const queue = queueRef.current;
    const frame = frameRef.current;
    const set = GLYPHS[glyphRef.current];
    const next: Cell[] = [];
    let done = 0;
    for (let i = 0; i < queue.length; i++) {
      const item = queue[i];
      if (!item) continue;
      if (frame >= item.end) {
        done++;
        next.push({ ch: item.to, state: "done" });
      } else if (frame >= item.start) {
        if (item.char === "" || Math.random() < 0.3) {
          item.char = set[Math.floor(Math.random() * set.length)] ?? item.to;
        }
        next.push({ ch: item.char, state: "run" });
      } else {
        next.push({ ch: item.from === "" ? " " : item.from, state: "idle" });
      }
    }
    setCells(next);
    if (done === queue.length) {
      rafRef.current = null;
      const resolve = resolveRef.current;
      resolveRef.current = null;
      if (resolve) resolve();
    } else {
      frameRef.current = frame + Math.max(1, speedRef.current);
      rafRef.current = requestAnimationFrame(tick);
    }
  }, []);

  const cancel = useCallback(() => {
    if (rafRef.current !== null) {
      cancelAnimationFrame(rafRef.current);
      rafRef.current = null;
    }
    resolveRef.current = null;
  }, []);

  const scramble = useCallback(
    (text: string) => {
      cancel();
      const previous = textRef.current;
      textRef.current = text;
      if (reducedRef.current) {
        setCells(text.split("").map((ch) => ({ ch, state: "done" as CellState })));
        return Promise.resolve();
      }
      const queue: QueueItem[] = text.split("").map((to, i) => {
        const start = Math.floor(Math.random() * 28);
        return {
          from: previous[i] ?? " ",
          to,
          start,
          end: start + 14 + Math.floor(Math.random() * 34),
          char: "",
        };
      });
      queueRef.current = queue;
      frameRef.current = 0;
      return new Promise<void>((resolve) => {
        resolveRef.current = resolve;
        rafRef.current = requestAnimationFrame(tick);
      });
    },
    [cancel, tick],
  );

  return { cells, scramble, cancel };
}

function RovingRadioGroup({
  label,
  options,
  value,
  onChange,
}: {
  label: string;
  options: readonly Option[];
  value: string;
  onChange: (value: string) => void;
}) {
  const refs = useRef<(HTMLButtonElement | null)[]>([]);
  const idx = options.findIndex((o) => o.value === value);

  const select = (target: number) => {
    const opt = options[target];
    if (!opt) return;
    onChange(opt.value);
    refs.current[target]?.focus();
  };

  const handleKey = (e: KeyboardEvent<HTMLButtonElement>) => {
    switch (e.key) {
      case "ArrowRight":
      case "ArrowDown":
        e.preventDefault();
        select((idx + 1 + options.length) % options.length);
        break;
      case "ArrowLeft":
      case "ArrowUp":
        e.preventDefault();
        select((idx - 1 + options.length) % options.length);
        break;
      case "Home":
        e.preventDefault();
        select(0);
        break;
      case "End":
        e.preventDefault();
        select(options.length - 1);
        break;
      default:
        break;
    }
  };

  return (
    <div role="radiogroup" aria-label={label} className="flex flex-wrap gap-2">
      {options.map((o, i) => {
        const checked = o.value === value;
        return (
          <button
            key={o.value}
            ref={(el) => {
              refs.current[i] = el;
            }}
            type="button"
            role="radio"
            aria-checked={checked}
            tabIndex={checked || (idx === -1 && i === 0) ? 0 : -1}
            onClick={() => onChange(o.value)}
            onKeyDown={handleKey}
            className={
              "rounded-full border px-3.5 py-1.5 text-sm font-medium transition-colors " +
              FOCUS_RING +
              (checked
                ? " border-indigo-500 bg-indigo-500 text-white shadow-sm shadow-indigo-500/30"
                : " border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:text-slate-900 dark:border-slate-700 dark:bg-slate-800/60 dark:text-slate-300 dark:hover:border-slate-500 dark:hover:text-white")
            }
          >
            {o.label}
          </button>
        );
      })}
    </div>
  );
}

export default function TxfxScramble() {
  const prefersReduced = useReducedMotion();
  const reduced = prefersReduced === true;

  const [activeIndex, setActiveIndex] = useState(0);
  const [glyphSet, setGlyphSet] = useState<GlyphSet>("glyphs");
  const [speed, setSpeed] = useState(2);
  const [autoPlay, setAutoPlay] = useState(false);
  const [settledToken, setSettledToken] = useState(0);
  const [liveText, setLiveText] = useState(MESSAGES[0].text);

  const { cells, scramble, cancel } = useScramble(reduced, glyphSet, speed);

  const mountedRef = useRef(true);
  const activeIndexRef = useRef(activeIndex);
  useEffect(() => {
    activeIndexRef.current = activeIndex;
  }, [activeIndex]);

  const runDecode = useCallback(
    (text: string) => {
      setLiveText(text);
      scramble(text).then(() => {
        if (mountedRef.current) setSettledToken((t) => t + 1);
      });
    },
    [scramble],
  );

  useEffect(() => {
    const text = MESSAGES[activeIndex]?.text ?? MESSAGES[0].text;
    runDecode(text);
  }, [activeIndex, runDecode]);

  const glyphMounted = useRef(false);
  useEffect(() => {
    if (!glyphMounted.current) {
      glyphMounted.current = true;
      return;
    }
    const text = MESSAGES[activeIndexRef.current]?.text ?? MESSAGES[0].text;
    runDecode(text);
  }, [glyphSet, runDecode]);

  useEffect(() => {
    if (!autoPlay) return;
    const id = window.setTimeout(() => {
      setActiveIndex((i) => (i + 1) % MESSAGES.length);
    }, 2400);
    return () => window.clearTimeout(id);
  }, [autoPlay, settledToken]);

  useEffect(() => {
    return () => {
      mountedRef.current = false;
      cancel();
    };
  }, [cancel]);

  const currentText = MESSAGES[activeIndex]?.text ?? MESSAGES[0].text;
  const resolving = cells.length > 0 && cells.some((c) => c.state !== "done");

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 dark:bg-slate-950 sm:py-24">
      <style>{`
        @keyframes txfx-scramble-blink { 0%, 49% { opacity: 1 } 50%, 100% { opacity: 0 } }
        @keyframes txfx-scramble-pulse { 0%, 100% { opacity: 1; transform: scale(1) } 50% { opacity: .35; transform: scale(.72) } }
        .txfx-scramble-cursor { display: inline-block; margin-left: .06em; transform: translateY(.06em); animation: txfx-scramble-blink 1.05s steps(1) infinite; }
        .txfx-scramble-dot { animation: txfx-scramble-pulse 1.6s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .txfx-scramble-cursor, .txfx-scramble-dot { animation: none !important; }
        }
      `}</style>

      <div
        aria-hidden="true"
        className="pointer-events-none absolute -top-24 left-1/2 h-72 w-72 -translate-x-1/2 rounded-full bg-indigo-400/20 blur-3xl dark:bg-indigo-500/20"
      />
      <div
        aria-hidden="true"
        className="pointer-events-none absolute -bottom-24 right-4 h-64 w-64 rounded-full bg-emerald-300/20 blur-3xl dark:bg-emerald-500/10"
      />

      <div className="relative mx-auto w-full max-w-4xl">
        <div className="flex items-center gap-2">
          <span className="txfx-scramble-dot inline-block h-2 w-2 rounded-full bg-emerald-500" />
          <span className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500 dark:text-slate-400">
            Text effect · Decode
          </span>
        </div>
        <h2 className="mt-4 text-3xl font-bold tracking-tight text-slate-900 dark:text-white sm:text-4xl">
          Scramble to reveal
        </h2>
        <p className="mt-3 max-w-xl text-base leading-relaxed text-slate-600 dark:text-slate-400">
          A decode animation that resolves plain text out of noise, left to right. Pick a message,
          swap the character set, and tune how fast it settles.
        </p>

        <div className="mt-8 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-xl shadow-slate-900/5 dark:border-slate-800 dark:bg-slate-900/70 dark:shadow-black/30">
          <div className="flex items-center gap-3 border-b border-slate-200 px-4 py-3 dark:border-slate-800">
            <span className="flex gap-1.5" aria-hidden="true">
              <span className="h-3 w-3 rounded-full bg-rose-400/80" />
              <span className="h-3 w-3 rounded-full bg-amber-400/80" />
              <span className="h-3 w-3 rounded-full bg-emerald-400/80" />
            </span>
            <span className="font-mono text-xs text-slate-400 dark:text-slate-500">reveal.stream</span>
            <span
              className={
                "ml-auto inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 font-mono text-[11px] font-medium " +
                (resolving
                  ? "bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300"
                  : "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300")
              }
            >
              <span
                className={
                  "txfx-scramble-dot inline-block h-1.5 w-1.5 rounded-full " +
                  (resolving ? "bg-amber-500" : "bg-emerald-500")
                }
              />
              {resolving ? "decoding" : "resolved"}
            </span>
          </div>

          <div className="px-6 py-8 sm:px-10 sm:py-12">
            <p
              aria-hidden="true"
              className="min-h-[6.5rem] whitespace-pre-wrap break-words font-mono text-2xl font-semibold leading-tight tracking-tight sm:min-h-[8rem] sm:text-4xl"
            >
              {cells.map((cell, i) => (
                <span
                  key={i}
                  className={
                    cell.state === "done"
                      ? "text-slate-900 dark:text-slate-100"
                      : cell.state === "run"
                        ? "text-emerald-500 dark:text-emerald-400"
                        : "text-slate-300 dark:text-slate-600"
                  }
                >
                  {cell.ch}
                </span>
              ))}
              <span className="txfx-scramble-cursor text-emerald-500 dark:text-emerald-400">▍</span>
            </p>
            <span className="sr-only" aria-live="polite">
              {liveText}
            </span>
          </div>
        </div>

        <div className="mt-6 grid gap-6 rounded-2xl border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900/70 sm:p-6">
          <div className="grid gap-2.5">
            <span className="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
              Message
            </span>
            <RovingRadioGroup
              label="Message to decode"
              options={MESSAGES.map((m, i) => ({ value: String(i), label: m.label }))}
              value={String(activeIndex)}
              onChange={(v) => setActiveIndex(Number(v))}
            />
          </div>

          <div className="grid gap-6 sm:grid-cols-2">
            <div className="grid gap-2.5">
              <span className="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
                Character set
              </span>
              <RovingRadioGroup
                label="Scramble character set"
                options={GLYPH_OPTIONS}
                value={glyphSet}
                onChange={(v) => setGlyphSet(v as GlyphSet)}
              />
            </div>

            <div className="grid content-start gap-2.5">
              <label
                htmlFor="txfx-scramble-speed"
                className="flex items-center justify-between text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400"
              >
                <span>Reveal speed</span>
                <span className="font-mono text-sm normal-case tracking-normal text-slate-700 dark:text-slate-200">
                  {speed}×
                </span>
              </label>
              <input
                id="txfx-scramble-speed"
                type="range"
                min={1}
                max={4}
                step={1}
                value={speed}
                onChange={(e) => setSpeed(Number(e.target.value))}
                className={"w-full accent-indigo-500 " + FOCUS_RING}
              />
            </div>
          </div>

          <div className="flex flex-wrap items-center gap-4 border-t border-slate-200 pt-5 dark:border-slate-800">
            <button
              type="button"
              onClick={() => runDecode(currentText)}
              className={
                "inline-flex items-center gap-2 rounded-full bg-slate-900 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-slate-700 dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200 " +
                FOCUS_RING
              }
            >
              <svg
                aria-hidden="true"
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                strokeWidth={2}
                strokeLinecap="round"
                strokeLinejoin="round"
                className="h-4 w-4"
              >
                <path d="M21 12a9 9 0 1 1-2.64-6.36" />
                <path d="M21 3v6h-6" />
              </svg>
              Replay decode
            </button>

            <button
              type="button"
              role="switch"
              aria-checked={autoPlay}
              onClick={() => setAutoPlay((v) => !v)}
              className={"inline-flex items-center gap-3 rounded-full " + FOCUS_RING}
            >
              <span
                className={
                  "relative inline-block h-6 w-11 rounded-full transition-colors " +
                  (autoPlay ? "bg-emerald-500" : "bg-slate-300 dark:bg-slate-700")
                }
              >
                <span
                  className={
                    "absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition-all " +
                    (autoPlay ? "left-[1.375rem]" : "left-0.5")
                  }
                />
              </span>
              <span className="text-sm font-medium text-slate-700 dark:text-slate-200">
                Auto-play messages
              </span>
            </button>
          </div>
        </div>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →