bottega

experimental

Text Scramble

A client component that animates text with a character-scramble effect, resolving letter-by-letter to the final string when it enters the viewport. Hydration-safe: Math.random runs only in useEffect, server and first-client render always show the final text. Reduced-motion: renders final text immediately with no scramble.


Install

1. Register the namespace (once per project):

json
// components.json — register the @bottega namespace once
{
  "registries": {
    "@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
  }
}

2. Add the component:

bash
npx shadcn add @bottega/text-scramble

Usage

Usagetsx
<TextScramble text="" />

Props

PropTypeDefaultDescription
text*string
speednumber40
scrambleCharsstringABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*

Source

text-scramble.tsxtsx
"use client";

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

const DEFAULT_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*";
// Each tick resolves one more character; interval in ms.
const DEFAULT_SPEED = 40;

export interface TextScrambleProps extends React.HTMLAttributes<HTMLSpanElement> {
  text: string;
  speed?: number;
  scrambleChars?: string;
}

export function TextScramble({
  text,
  speed = DEFAULT_SPEED,
  scrambleChars = DEFAULT_CHARS,
  className = "",
  ...props
}: TextScrambleProps) {
  const [mounted, setMounted] = useState(false);
  useEffect(() => { setMounted(true); }, []);

  const prefersReducedMotion = useReducedMotion();
  // null (SSR) => treat as reduced so server ≡ first client render (prevents #418)
  const reduce = prefersReducedMotion === null || prefersReducedMotion;

  // displayed holds the transient scramble; when !animating it equals `text`.
  const [displayed, setDisplayed] = useState(text);
  const [inView, setInView] = useState(false);
  const containerRef = useRef<HTMLSpanElement>(null);
  const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);

  // IntersectionObserver — trigger once on first entrance.
  useEffect(() => {
    const el = containerRef.current;
    if (!el) return;
    const obs = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setInView(true);
          obs.disconnect();
        }
      },
      { threshold: 0.1 }
    );
    obs.observe(el);
    return () => obs.disconnect();
  }, []);

  // Scramble animation — runs only when mounted + inView + not reduced.
  useEffect(() => {
    if (!mounted || reduce || !inView) return;

    let resolvedCount = 0;
    // The first interval tick (below) sets the scrambled value; no reset needed here.

    intervalRef.current = setInterval(() => {
      resolvedCount++;
      if (resolvedCount >= text.length) {
        setDisplayed(text);
        if (intervalRef.current) clearInterval(intervalRef.current);
        return;
      }
      const resolved = text.slice(0, resolvedCount);
      // Build remaining chars as random glyphs — Math.random is safe here (in useEffect).
      const scrambled = Array.from(
        { length: text.length - resolvedCount },
        () => scrambleChars[Math.floor(Math.random() * scrambleChars.length)]
      ).join("");
      setDisplayed(resolved + scrambled);
    }, speed);

    return () => {
      if (intervalRef.current) clearInterval(intervalRef.current);
    };
  }, [mounted, reduce, inView, text, speed, scrambleChars]);

  return (
    <span
      ref={containerRef}
      className={["text-foreground font-mono", className].filter(Boolean).join(" ")}
      // Expose the final, meaningful text to assistive technology.
      aria-label={text}
      {...props}
    >
      {/* aria-hidden keeps the transient glyphs out of the a11y tree. */}
      <span aria-hidden="true">{displayed}</span>
    </span>
  );
}

Dependencies

  • motion
  • @bottega/tokens