Scramble Text

Scrambles characters then resolves them into the final text.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/scramble-text.json

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

2 stories available:

Code

"use client"; import * as React from "react"; import { cn } from "../../lib/utils"; /** Props for {@link ScrambleText}. */ export type ScrambleTextProps = React.ComponentPropsWithoutRef<"span"> & { /** Milliseconds for the full resolve. Defaults to `1200`. */ duration?: number; /** Pool of glyphs used while scrambling. */ scrambleCharacters?: string; /** Final resolved text. */ text: string; }; function usePrefersReducedMotion(): boolean { const [reduced, setReduced] = React.useState(false); React.useEffect(() => { if ( typeof window === "undefined" || typeof window.matchMedia !== "function" ) { return; } const query = window.matchMedia("(prefers-reduced-motion: reduce)"); const onChange = (): void => { setReduced(query.matches); }; onChange(); query.addEventListener("change", onChange); return () => { query.removeEventListener("change", onChange); }; }, []); return reduced; } function scramble(text: string, revealed: number, pool: string): string { const characters = text.match(/[\s\S]/gu) ?? []; return characters .map((character, index) => { if (index < revealed || character === " ") { return character; } return pool.charAt(Math.floor(Math.random() * pool.length)); }) .join(""); } const defaultPool = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; /** * Resolves text left-to-right out of randomized glyphs on mount. * * Respects `prefers-reduced-motion`: the final text shows at once. * * @example * ```tsx * <ScrambleText text="DECRYPTED" /> * ``` */ export const ScrambleText = ({ className, duration = 1200, ref, scrambleCharacters = defaultPool, text, ...props }: ScrambleTextProps & { ref?: React.Ref<HTMLSpanElement> }) => { const reduced = usePrefersReducedMotion(); const [display, setDisplay] = React.useState(text); React.useEffect(() => { if (reduced) { setDisplay(text); return; } const steps = Math.max(text.length, 1); const tick = duration / steps; let revealed = 0; const timer = setInterval(() => { revealed += 1; setDisplay(scramble(text, revealed, scrambleCharacters)); if (revealed >= text.length) { clearInterval(timer); } }, tick); return () => { clearInterval(timer); }; }, [duration, reduced, scrambleCharacters, text]); return ( <span aria-label={text} className={cn("font-mono", className)} ref={ref} {...props} > <span aria-hidden="true">{display}</span> </span> ); }; ScrambleText.displayName = "ScrambleText";

Dependencies

  • @vllnt/ui@^0.2.1