Typography
FreeDecode 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.
npx @galactrex/ui add decode-textUsage
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.
"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
| Prop | Type | Default | Description |
|---|---|---|---|
text | string | — | The final text. |
as | ElementType | "span" | Element to render, e.g. "h1". |
trigger | "mount" | "inView" | "hover" | "inView" | When it decodes. |
duration | number | 900 | Ms to resolve the whole text. |
delay | number | 0 | Ms before it starts. |
glyphs | string | — | Characters used while scrambling. |
className | string | — | 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.