Account

Typography

Free

Decode Text

A headline or status that arrives like a signal locking on. The real text sets the size from the first frame, so nothing around it moves.

Install

No key needed.

Terminal
npx @galactrex/ui add decode-text

Usage

Example
import { DecodeText } from "@/components/galactrex/decode-text";

<DecodeText as="h1" text="Signal acquired" trigger="mount" />

Source

Installs to components/galactrex/decode-text.tsx, with the shared motion primitives beside it.

components/galactrex/decode-text.tsx
"use client";
import { useEffect, useRef, useState, type ElementType } from "react";
import { useInView } from "motion/react";
import { cx } from "@/components/galactrex/motion/cx";
import { useReducedMotion } from "@/components/galactrex/motion/use-reduced-motion";

export type DecodeTextProps = {
  text: string;
  /** element to render, e.g. "h1" or "p" */
  as?: ElementType;
  /** mount: as soon as it renders; inView: when it scrolls into view; hover: each time a mouse enters */
  trigger?: "mount" | "inView" | "hover";
  /** ms for the whole text to resolve */
  duration?: number;
  /** ms before decoding starts */
  delay?: number;
  /** glyphs used while scrambling */
  glyphs?: string;
  className?: string;
};

const GLYPHS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#%&*+=/<>";

/** Characters resolve from scrambled glyphs, left to right. The real text sets the size from the first frame,
    so nothing around it moves, and screen readers get the real text immediately. Frames are written straight
    to the DOM, so decoding does not re-render React. */
export function DecodeText({ text, as: Tag = "span", trigger = "inView", duration = 900, delay = 0, glyphs = GLYPHS, className }: DecodeTextProps) {
  const host = useRef<HTMLElement>(null);
  const out = useRef<HTMLSpanElement>(null);
  const reduced = useReducedMotion();
  const seen = useInView(host, { once: true, amount: 0.6 });
  const [hovers, setHovers] = useState(0);
  // which decode this is: 0 shows the text as is; each new number plays once
  const round = trigger === "hover" ? hovers : trigger === "mount" || seen ? 1 : 0;

  useEffect(() => {
    const el = out.current;
    if (!el) return;
    if (reduced || round === 0) {
      el.textContent = text;
      return;
    }
    let frame = 0;
    let last = 0;
    const began = performance.now() + delay;
    const tick = (now: number) => {
      const t = Math.min(1, Math.max(0, (now - began) / Math.max(1, duration)));
      if (now - last > 40 || t === 1) {
        last = now;
        const fixed = Math.floor(t * text.length);
        let s = text.slice(0, fixed);
        for (let i = fixed; i < text.length; i++) {
          const c = text[i];
          s += c === " " || c === "\n" ? c : glyphs[(Math.random() * glyphs.length) | 0];
        }
        el.textContent = s;
      }
      if (t < 1) frame = requestAnimationFrame(tick);
    };
    frame = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(frame);
  }, [round, text, duration, delay, glyphs, reduced]);

  return (
    <Tag
      ref={host}
      className={cx("relative inline-block", className)}
      onPointerEnter={trigger === "hover" ? (e: React.PointerEvent) => { if (e.pointerType !== "touch") setHovers((r) => r + 1); } : undefined}
    >
      <span className="sr-only">{text}</span>
      <span aria-hidden className="invisible">{text}</span>
      <span ref={out} aria-hidden className="absolute inset-0 whitespace-pre-wrap">{text}</span>
    </Tag>
  );
}

Props

PropTypeDefaultDescription
textstring—The final text.
asElementType"span"Element to render, e.g. "h1".
trigger"mount" | "inView" | "hover""inView"When it decodes.
durationnumber900Ms to resolve the whole text.
delaynumber0Ms before it starts.
glyphsstring—Characters used while scrambling.
classNamestring—Extra classes for the outer element.

Variants

  • Headline. `as="h1"` with `trigger="mount"`.
  • Hover label. Re-decodes each time a mouse enters.

Accessibility

  • Screen readers get the real text immediately; the scramble is aria-hidden.
  • No layout shift: the real text reserves the space.

Motion and reduced motion

  • Frames are written to the DOM directly, about 25 per second, without React re-renders.
  • Reduced motion: the text is shown as is.