{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "scramble-text", "title": "Scramble Text", "description": "A hover-driven text scramble animation with a smooth decode sweep.", "dependencies": [ "motion" ], "files": [ { "path": "registry/new-york/components/scramble-text/scramble-text.tsx", "type": "registry:component", "content": "\"use client\";\n\nimport { useState, useEffect, useRef, useMemo } from \"react\";\nimport { useReducedMotion } from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\n// The mix of technical glyphs used for decoding\nconst GLYPHS = \"!<>-_\\\\\\\\/[]{}—=+*^?#\";\n\nexport interface ScrambleTextProps {\n /** Text to animate. */\n text: string;\n /** Optional link destination. */\n href?: string;\n /** Additional classes for the interactive text element. */\n className?: string;\n /** Milliseconds spent decoding each character. */\n characterDuration?: number;\n /** Milliseconds between random-character updates. */\n updateInterval?: number;\n /** Color used while a character is scrambled. */\n scrambledColor?: string;\n /** Opacity used while a character is scrambled. */\n scrambledOpacity?: number;\n}\n\nconst ScrambleText = ({\n text,\n href = \"#\",\n className,\n characterDuration = 80,\n updateInterval = 60,\n scrambledColor = \"var(--muted-foreground)\",\n scrambledOpacity = 0.83,\n}: ScrambleTextProps) => {\n const baseChars = useMemo(\n () => text.split(\"\").map((char) => ({ char, isScrambled: false })),\n [text]\n );\n const [charArray, setCharArray] = useState(baseChars);\n const [isHovered, setIsHovered] = useState(false);\n const prefersReducedMotion = useReducedMotion();\n\n const animationRef = useRef(null);\n\n useEffect(() => {\n if (!isHovered || prefersReducedMotion) {\n if (animationRef.current) cancelAnimationFrame(animationRef.current);\n return;\n }\n\n let startTimestamp = 0;\n let lastMutationTime = 0;\n\n const animate = (currentTime: number) => {\n if (!startTimestamp) startTimestamp = currentTime;\n const elapsedTime = currentTime - startTimestamp;\n\n // Smoothly calculate the continuous sweep position based on elapsed time.\n // 80ms per character means it glides smoothly.\n // The -2 offset gives a brief initial burst where the whole word is scrambled.\n const iteration = (elapsedTime / characterDuration) - 2;\n\n // Reduce char flipping speed to 60ms (approx ~16fps) for a readable, smooth glitch effect\n if (currentTime - lastMutationTime >= updateInterval) {\n setCharArray(() => {\n return text.split(\"\").map((letter, index) => {\n if (letter === \" \") return { char: \" \", isScrambled: false };\n\n // Lock the character in if the sweep has reached it\n if (index < Math.floor(iteration)) {\n return { char: text[index], isScrambled: false };\n }\n\n // Otherwise show a random character\n return {\n char: GLYPHS[Math.floor(Math.random() * GLYPHS.length)],\n isScrambled: true\n };\n });\n });\n\n lastMutationTime = currentTime;\n }\n\n // Continue until the sweep has fully passed the end of the text\n if (iteration < text.length) {\n animationRef.current = requestAnimationFrame(animate);\n } else {\n setCharArray(text.split(\"\").map(char => ({ char, isScrambled: false })));\n }\n };\n\n animationRef.current = requestAnimationFrame(animate);\n\n return () => {\n if (animationRef.current) cancelAnimationFrame(animationRef.current);\n };\n }, [\n isHovered,\n text,\n prefersReducedMotion,\n characterDuration,\n updateInterval,\n ]);\n\n const displayChars = !isHovered || prefersReducedMotion ? baseChars : charArray;\n\n return (\n {\n setIsHovered(true);\n setCharArray(baseChars);\n }}\n onMouseLeave={() => setIsHovered(false)}\n onFocus={() => setIsHovered(true)}\n onBlur={() => setIsHovered(false)}\n className={cn(\n \"group relative inline-flex items-center cursor-pointer text-4xl font-bold leading-normal tracking-[0.08em] text-foreground no-underline uppercase sm:text-5xl\",\n className,\n )}\n >\n {text}\n\n {/* Invisible structural text to permanently freeze the layout element's exact final width.\n This prevents the parent flex container from constantly re-centering\n when random characters are wider or narrower! */}\n \n {text}\n \n\n {/* The animated characters overlay, anchored rigidly to the left.\n Any width expansion/contraction from random glyphs will exclusively bleed to the right. */}\n \n
\n {displayChars.map((item, index) => (\n \n {item.char}\n
\n ))}\n \n
\n \n );\n};\n\nexport default ScrambleText;\n" } ], "type": "registry:component" }