Text Scramble
Text that decrypts character by character with a scramble effect. Trigger on scroll, hover, or mount.
Last updated Mar 5, 2026
Component
"use client";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
const DEFAULT_CHARS = "!@#$%^&*()_+-=[]{}|;:,.<>?";
interface TextScrambleProps {
text: string;
className?: string;
chars?: string;
trigger?: "inView" | "mount" | "hover";
scrambleSpeed?: number;
cursorClassName?: string;
inViewThreshold?: number;
onComplete?: () => void;
}
export function TextScramble({
text,
className,
chars = DEFAULT_CHARS,
trigger = "inView",
scrambleSpeed = 0,
cursorClassName,
inViewThreshold = 0.3,
onComplete,
}: TextScrambleProps) {
const [display, setDisplay] = useState("");
const [done, setDone] = useState(false);
const ref = useRef<HTMLSpanElement>(null);
const hasAnimatedRef = useRef(false);
const [inView, setInView] = useState(trigger === "mount");
const [hovered, setHovered] = useState(false);
useEffect(() => {
if (trigger !== "inView") {
return;
}
const el = ref.current;
if (!el) {
return;
}
const obs = new IntersectionObserver(
([e]) => setInView(e?.isIntersecting ?? false),
{
threshold: inViewThreshold,
},
);
obs.observe(el);
return () => obs.disconnect();
}, [trigger, inViewThreshold]);
const shouldRun =
trigger === "mount"
? true
: trigger === "inView"
? inView && !hasAnimatedRef.current
: hovered;
useEffect(() => {
if (!shouldRun) {
return;
}
if (trigger === "inView" && hasAnimatedRef.current) {
return;
}
let id: number;
const resolve = (i: number) => {
if (i >= text.length) {
hasAnimatedRef.current = true;
setDone(true);
onComplete?.();
return;
}
const scramble = text
.split("")
.map((c, j) =>
j <= i
? c
: (chars[
Math.floor(Math.random() * chars.length)
] ?? "?"),
)
.join("");
setDisplay(scramble);
id = window.setTimeout(
() => resolve(i + 1),
scrambleSpeed,
);
};
id = window.setTimeout(() => resolve(0), scrambleSpeed);
return () => clearTimeout(id);
}, [
shouldRun,
trigger,
text,
chars,
scrambleSpeed,
onComplete,
]);
const content = (
<span ref={ref} className={cn(className)}>
{display}
{!done && (
<span
className={cn("animate-pulse", cursorClassName)}
aria-hidden={true}
>
|
</span>
)}
</span>
);
if (trigger === "hover") {
return (
// biome-ignore lint/a11y/useSemanticElements: hover trigger for decorative scramble, not a form group
<div
className="inline"
role="group"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{content}
</div>
);
}
return content;
}Installation
pnpm dlx shadcn@latest add "https://pulkitxm.com/components/text-scramble.json"1. Copy the component file
"use client";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
const DEFAULT_CHARS = "!@#$%^&*()_+-=[]{}|;:,.<>?";
interface TextScrambleProps {
text: string;
className?: string;
chars?: string;
trigger?: "inView" | "mount" | "hover";
scrambleSpeed?: number;
cursorClassName?: string;
inViewThreshold?: number;
onComplete?: () => void;
}
export function TextScramble({
text,
className,
chars = DEFAULT_CHARS,
trigger = "inView",
scrambleSpeed = 0,
cursorClassName,
inViewThreshold = 0.3,
onComplete,
}: TextScrambleProps) {
const [display, setDisplay] = useState("");
const [done, setDone] = useState(false);
const ref = useRef<HTMLSpanElement>(null);
const hasAnimatedRef = useRef(false);
const [inView, setInView] = useState(trigger === "mount");
const [hovered, setHovered] = useState(false);
useEffect(() => {
if (trigger !== "inView") {
return;
}
const el = ref.current;
if (!el) {
return;
}
const obs = new IntersectionObserver(
([e]) => setInView(e?.isIntersecting ?? false),
{
threshold: inViewThreshold,
},
);
obs.observe(el);
return () => obs.disconnect();
}, [trigger, inViewThreshold]);
const shouldRun =
trigger === "mount"
? true
: trigger === "inView"
? inView && !hasAnimatedRef.current
: hovered;
useEffect(() => {
if (!shouldRun) {
return;
}
if (trigger === "inView" && hasAnimatedRef.current) {
return;
}
let id: number;
const resolve = (i: number) => {
if (i >= text.length) {
hasAnimatedRef.current = true;
setDone(true);
onComplete?.();
return;
}
const scramble = text
.split("")
.map((c, j) =>
j <= i
? c
: (chars[
Math.floor(Math.random() * chars.length)
] ?? "?"),
)
.join("");
setDisplay(scramble);
id = window.setTimeout(
() => resolve(i + 1),
scrambleSpeed,
);
};
id = window.setTimeout(() => resolve(0), scrambleSpeed);
return () => clearTimeout(id);
}, [
shouldRun,
trigger,
text,
chars,
scrambleSpeed,
onComplete,
]);
const content = (
<span ref={ref} className={cn(className)}>
{display}
{!done && (
<span
className={cn("animate-pulse", cursorClassName)}
aria-hidden={true}
>
|
</span>
)}
</span>
);
if (trigger === "hover") {
return (
// biome-ignore lint/a11y/useSemanticElements: hover trigger for decorative scramble, not a form group
<div
className="inline"
role="group"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{content}
</div>
);
}
return content;
}2. Import and use
import { TextScramble } from "@/components/text-scramble";
<TextScramble text="Your text here" trigger="inView" />;Usage
Import
Add the TextScramble import.
import { TextScramble } from "@/components/text-scramble";Use
Use with text and trigger mode.
<TextScramble text="Your text" trigger="inView" />;Guidelines
- Use trigger='inView' for hero headings that animate when scrolled into view.
- Use trigger='hover' for interactive reveals.
- scrambleSpeed: delay in ms between characters (0 = fastest).
- Customize chars for different scramble character sets (e.g. '01' for binary).
Props
All props are optional unless marked required. Use these to customize every aspect of the component.
| Prop | Type | Default | Description |
|---|---|---|---|
| text | string | — | The text to scramble and reveal. |
| chars | string | "!@#$%^&*()_+-=[]{}|;:,.<>?" | Characters to cycle through during scramble. |
| trigger | "inView" | "mount" | "hover" | "inView" | When to trigger: "inView" (scroll), "mount", or "hover". |
| scrambleSpeed | number | 0 | Delay in ms between characters. 0 = fastest (requestAnimationFrame). |
| cursorClassName | string | — | Classes for the blinking cursor during scramble. |
| inViewThreshold | number | 0.3 | IntersectionObserver threshold (0–1) for inView trigger. |
| className | string | — | Additional CSS classes. |
| onComplete | () => void | — | Callback when scramble completes. |
Examples
On scroll (inView)
Scramble animates when the text enters the viewport.
import { TextScramble } from "@/components/text-scramble";
<TextScramble
text="Build Something Beautiful"
trigger="inView"
/>;On mount
Scramble runs immediately on mount.
import { TextScramble } from "@/components/text-scramble";
<TextScramble text="Hello World" trigger="mount" />;On hover
Scramble runs when the user hovers over the text.
import { TextScramble } from "@/components/text-scramble";
<span className="text-2xl">
<TextScramble text="Hover to reveal" trigger="hover" />
</span>;Speed & chars
50ms delay per character, binary chars, custom cursor color.
import { TextScramble } from "@/components/text-scramble";
<TextScramble
text="Slower reveal"
scrambleSpeed={50}
chars="01"
cursorClassName="text-amber-500"
/>;Threshold & callback
Triggers when 50% visible; onComplete callback.
import { TextScramble } from "@/components/text-scramble";
<TextScramble
text="Custom threshold"
inViewThreshold={0.5}
onComplete={() => console.log("Done!")}
/>;