Back to components

Text Decode

Random characters settle into readable text, one character at a time. Change the text to decode a new message.

motion-primitivedecode
transmission
UTF–8

A MESSAGE FOR YOU

Hello, human.

A little noise. Then everything makes sense.

"use client";

import { useEffect, useRef, type ComponentProps } from "react";

import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/hooks/use-reduced-motion";

export type TextDecodeProps = Omit<ComponentProps<"span">, "children"> & {
  children: string;
  /** Total time in milliseconds, including the last character settling. */
  duration?: number;
  /** Increment to decode the same text again. Text changes also replay. */
  replayKey?: string | number;
  characters?: string;
};

/** Scramble, then lock each grapheme from left to right. No per-frame React renders. */
export function TextDecode({
  children,
  duration = 900,
  replayKey = 0,
  characters = "01ABCDEF<>/{}",
  className,
  ...props
}: TextDecodeProps) {
  const visualRef = useRef<HTMLSpanElement>(null);
  const reducedMotion = useReducedMotion();

  useEffect(() => {
    const visual = visualRef.current;
    if (!visual) return;
    visual.textContent = children;
    if (
      reducedMotion ||
      duration <= 0 ||
      !Number.isFinite(duration) ||
      !children
    )
      return;

    const segmenter = new Intl.Segmenter(undefined, {
      granularity: "grapheme",
    });
    const letters = Array.from(
      segmenter.segment(children),
      ({ segment }) => segment,
    );
    const alphabet = Array.from(
      segmenter.segment(characters),
      ({ segment }) => segment,
    );
    if (!alphabet.length) return;
    const started = performance.now();
    let frame = 0;
    let lastTick = -1;
    const draw = (now: number) => {
      const progress = Math.min((now - started) / duration, 1);
      if (progress >= 1) {
        visual.textContent = children;
        return;
      }
      // Limit text mutations to 25fps while using a single cancellable clock.
      const tick = Math.floor((now - started) / 40);
      if (tick !== lastTick) {
        lastTick = tick;
        const locked = Math.floor(progress * letters.length);
        visual.textContent = letters
          .map((letter, index) =>
            index < locked || !letter.trim()
              ? letter
              : alphabet[Math.floor(Math.random() * alphabet.length)],
          )
          .join("");
      }
      frame = requestAnimationFrame(draw);
    };
    draw(started);
    return () => {
      cancelAnimationFrame(frame);
      visual.textContent = children;
    };
  }, [children, duration, replayKey, characters, reducedMotion]);

  return (
    <span
      {...props}
      data-slot="text-decode"
      className={cn("relative inline-grid", className)}
    >
      <span className="sr-only">{children}</span>
      <span
        aria-hidden="true"
        className="invisible col-start-1 row-start-1 whitespace-pre-wrap"
      >
        {children}
      </span>
      <span
        ref={visualRef}
        aria-hidden="true"
        className="col-start-1 row-start-1 whitespace-pre-wrap"
      >
        {children}
      </span>
    </span>
  );
}

The pattern

A line of noise settles into a message, one character at a time. The text stays in place while the characters change.

The preview is just one example. The component carries the motion; you bring the content.

Make it yours

Replace the text, adjust the total duration, or supply your own characters for the noise. Use a monospace font for a steady rhythm. Text changes replay automatically; increment replayKey to repeat the same message. Chinese and emoji stay intact. Screen readers receive the final message, and reduced motion skips the scramble.

example.tsx
"use client";

import { useState } from "react";
import { TextDecode } from "@/components/ui/text-decode";

export function Example() {
  const [run, setRun] = useState(0);
  return (
    <div className="flex flex-col items-start gap-4">
      <TextDecode replayKey={run} duration={1100} className="font-mono text-2xl">
        Hello, human.
      </TextDecode>
      <button type="button" onClick={() => setRun((value) => value + 1)}>
        Decode again
      </button>
    </div>
  );
}

Installation