GSAP ScrambleTextPlugin cycles through eight phrases (English and Japanese) on a 3-second interval, replacing each character through a randomised intermediate character set before revealing the target. Five switchable character pools -- binary (01), hex, katakana, symbols, block characters -- change the visual texture of the scramble mid-stream. The revealDelay of 0.3 and speed of 0.4 mean each letter dwells in its scrambled state briefly before snapping to the correct glyph, and the overall tween duration of 1.2 s fills most of the 3 s cycle, keeping the text in near-constant motion.
Source
"use client";
import { useRef, useState, useEffect, useCallback } from "react";
import gsap from "gsap";
import { ScrambleTextPlugin } from "gsap/ScrambleTextPlugin";
import ScrambleLink from "../ScrambleLink";
gsap.registerPlugin(ScrambleTextPlugin);
const PHRASES = [
"GIRAGIRA",
"ギラギラ",
"DAZZLING",
"BLINDING",
"RADIANT",
"眩しい",
"LUMINOUS",
"GLITTER",
];
const CHAR_SETS: { label: string; chars: string }[] = [
{ label: "Binary", chars: "01" },
{ label: "Hex", chars: "0123456789ABCDEF" },
{ label: "Katakana", chars: "アイウエオカキクケコサシスセソ" },
{ label: "Symbols", chars: "!@#$%^&*()_+-=[]{}|;:,.<>?" },
{ label: "Blocks", chars: "░▒▓█▄▀▌▐" },
];
export default function ScrambleType() {
const containerRef = useRef<HTMLDivElement>(null);
const textRef = useRef<HTMLDivElement>(null);
const [charSet, setCharSet] = useState(0);
const [dark, setDark] = useState(true);
const [phraseIdx, setPhraseIdx] = useState(0);
const tweenRef = useRef<gsap.core.Tween | null>(null);
const scrambleTo = useCallback(
(idx: number) => {
if (!textRef.current) return;
tweenRef.current?.kill();
tweenRef.current = gsap.to(textRef.current, {
duration: 1.2,
scrambleText: {
text: PHRASES[idx],
chars: CHAR_SETS[charSet].chars,
revealDelay: 0.3,
speed: 0.4,
},
ease: "none",
});
},
[charSet]
);
useEffect(() => {
scrambleTo(phraseIdx);
}, [phraseIdx, scrambleTo]);
useEffect(() => {
const interval = setInterval(() => {
setPhraseIdx((i) => (i + 1) % PHRASES.length);
}, 3000);
return () => clearInterval(interval);
}, []);
return (
<div
ref={containerRef}
className="fixed inset-0 flex items-center justify-center select-none overflow-hidden"
style={{
background: dark ? "#0a0a0a" : "#f8f7f4",
transition: "background 0.4s ease",
}}
>
<div
ref={textRef}
style={{
fontFamily: "var(--font-flux), monospace",
fontVariationSettings: "'wght' 600, 'SRIF' 0",
fontSize: "clamp(48px, 10vw, 120px)",
color: dark ? "#fff" : "#1a1a2e",
letterSpacing: "0.05em",
textAlign: "center",
minWidth: "8ch",
}}
>
{PHRASES[0]}
</div>
{/* Phrase buttons */}
<div
className="fixed bottom-12 left-1/2 -translate-x-1/2 flex gap-2 flex-wrap justify-center"
style={{ zIndex: 20, maxWidth: 600 }}
>
{PHRASES.map((p, i) => (
<button
key={p}
onClick={() => setPhraseIdx(i)}
style={{
fontFamily: "var(--font-flux), monospace",
fontVariationSettings: "'wght' 300, 'SRIF' 100",
fontSize: 11,
letterSpacing: "0.06em",
padding: "5px 12px",
borderRadius: 20,
border:
i === phraseIdx
? dark
? "1px solid rgba(255,255,255,0.4)"
: "1px solid rgba(26,26,46,0.3)"
: dark
? "1px solid rgba(255,255,255,0.1)"
: "1px solid rgba(0,0,0,0.08)",
background:
i === phraseIdx
? dark
? "rgba(255,255,255,0.1)"
: "rgba(26,26,46,0.06)"
: "transparent",
color: dark ? "#fff" : "#1a1a2e",
cursor: "pointer",
transition: "all 0.2s ease",
}}
>
{p}
</button>
))}
</div>
{/* Controls */}
<div
style={{
position: "fixed",
right: 24,
top: "50%",
transform: "translateY(-50%)",
zIndex: 20,
width: 200,
background: dark ? "rgba(20,20,20,0.92)" : "rgba(255,255,255,0.92)",
backdropFilter: "blur(12px)",
borderRadius: 16,
padding: "20px 22px",
boxShadow: dark
? "0 2px 20px rgba(0,0,0,0.3)"
: "0 2px 20px rgba(0,0,0,0.06)",
border: dark
? "1px solid rgba(255,255,255,0.08)"
: "1px solid rgba(0,0,0,0.06)",
transition: "all 0.4s ease",
}}
>
<span
style={{
fontFamily: "var(--font-flux), sans-serif",
fontVariationSettings: "'wght' 500, 'SRIF' 100",
fontSize: 12,
letterSpacing: "0.1em",
textTransform: "uppercase",
color: dark ? "#fff" : "#1a1a2e",
}}
>
Scramble
</span>
<div style={{ marginTop: 16, display: "flex", flexDirection: "column", gap: 6 }}>
{CHAR_SETS.map((cs, i) => (
<button
key={cs.label}
onClick={() => {
setCharSet(i);
setPhraseIdx((p) => p);
}}
style={{
fontFamily: "var(--font-flux), sans-serif",
fontVariationSettings: "'wght' 300, 'SRIF' 100",
fontSize: 11,
letterSpacing: "0.06em",
textAlign: "left",
padding: "6px 10px",
borderRadius: 8,
border:
i === charSet
? dark
? "1px solid rgba(255,255,255,0.3)"
: "1px solid rgba(26,26,46,0.2)"
: dark
? "1px solid rgba(255,255,255,0.08)"
: "1px solid rgba(0,0,0,0.06)",
background:
i === charSet
? dark
? "rgba(255,255,255,0.1)"
: "rgba(26,26,46,0.06)"
: "transparent",
color: dark ? "#fff" : "#1a1a2e",
cursor: "pointer",
transition: "all 0.2s ease",
}}
>
{cs.label}
<span style={{ opacity: 0.3, marginLeft: 8 }}>{cs.chars.slice(0, 4)}</span>
</button>
))}
</div>
<button
onClick={() => setDark((v) => !v)}
style={{
width: "100%",
padding: "8px 0",
marginTop: 16,
borderRadius: 8,
border: dark
? "1px solid rgba(255,255,255,0.2)"
: "1px solid rgba(26,26,46,0.1)",
background: dark
? "rgba(255,255,255,0.1)"
: "rgba(26,26,46,0.04)",
color: dark ? "#fff" : "#1a1a2e",
fontFamily: "var(--font-flux), sans-serif",
fontVariationSettings: "'wght' 500, 'SRIF' 100",
fontSize: 11,
letterSpacing: "0.08em",
textTransform: "uppercase" as const,
cursor: "pointer",
transition: "all 0.3s ease",
}}
>
{dark ? "Dark" : "Light"}
</button>
</div>
{/* Nav */}
<div className="fixed bottom-6 left-6" style={{ zIndex: 20 }}>
<ScrambleLink
from="SCRAMBLE"
to="← LAB"
href="/lab"
className="text-[11px] tracking-[0.1em] text-foreground/30 uppercase hover:text-foreground/60 transition-colors"
style={{ fontVariationSettings: "'wght' 400, 'SRIF' 100" }}
/>
</div>
</div>
);
}