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-scrambleUsage
Usagetsx
<TextScramble text="" />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| text* | string | — | |
| speed | number | 40 | |
| scrambleChars | string | ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&* |
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