{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "scramble-text", "type": "registry:ui", "title": "Scramble Text", "description": "Hover triggers a decode sequence — characters dissolve into random glyphs then resolve in a directional wave that follows your entry point, each locking in with a spring pop.", "dependencies": [ "motion" ], "registryDependencies": [], "files": [ { "path": "registry/ruixenui/scramble-text.tsx", "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { motion } from \"motion/react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\n\n/* ═══════════════════════════════════════════════════════════\n Scramble Text — hover triggers a decode sequence. Characters\n dissolve into random glyphs, then resolve in a directional\n wave that follows your entry point. Left-side entry sweeps\n left-to-right; right-side entry sweeps right-to-left. The\n direction is discovered, not configured.\n\n Each character locks in with a three-spring pop: y snaps up\n from below (stiffness 500, damping 20 — visible overshoot),\n scaleY expands from compressed (600/24 — crisp snap), and\n opacity fades in (400/28 — smooth). The staggered wave of\n these pops is the entire identity of the component.\n ═══════════════════════════════════════════════════════════ */\n\ntype TextElement = \"p\" | \"span\" | \"h1\" | \"h2\" | \"h3\" | \"h4\" | \"h5\" | \"h6\";\n\ninterface ScrambleTextProps {\n children: string;\n as?: TextElement;\n className?: string;\n}\n\nconst GLYPHS =\n \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%&*\";\nconst TICK = 30;\nconst STAGGER = 1.4;\n\nfunction glyph() {\n return GLYPHS[Math.floor(Math.random() * GLYPHS.length)];\n}\n\ninterface Cell {\n char: string;\n locked: boolean;\n}\n\nexport default function ScrambleText({\n children,\n as: Tag = \"p\",\n className,\n}: ScrambleTextProps) {\n const text = children;\n const len = text.length;\n const active = useRef(false);\n const timer = useRef>();\n\n const [cells, setCells] = useState(() =>\n text.split(\"\").map((c) => ({ char: c, locked: true })),\n );\n\n useEffect(() => {\n setCells(text.split(\"\").map((c) => ({ char: c, locked: true })));\n }, [text]);\n\n const play = useCallback(\n (fromLeft: boolean) => {\n if (active.current) return;\n active.current = true;\n let tick = 0;\n\n setCells(\n text\n .split(\"\")\n .map((c) =>\n c === \" \"\n ? { char: \" \", locked: true }\n : { char: glyph(), locked: false },\n ),\n );\n\n timer.current = setInterval(() => {\n tick++;\n\n setCells(\n text.split(\"\").map((c, i) => {\n if (c === \" \") return { char: \" \", locked: true };\n const order = fromLeft ? i : len - 1 - i;\n const resolveAt = Math.floor(order * STAGGER) + 2;\n if (tick >= resolveAt) return { char: c, locked: true };\n return { char: glyph(), locked: false };\n }),\n );\n\n const end = Math.floor((len - 1) * STAGGER) + 5;\n if (tick >= end) {\n clearInterval(timer.current);\n setCells(text.split(\"\").map((c) => ({ char: c, locked: true })));\n active.current = false;\n }\n }, TICK);\n },\n [text, len],\n );\n\n useEffect(() => () => clearInterval(timer.current), []);\n\n return (\n ) => {\n const rect = e.currentTarget.getBoundingClientRect();\n play(e.clientX - rect.left < rect.width / 2);\n }}\n >\n {cells.map((cell, i) => (\n \n {cell.char === \" \" ? \"\\u00A0\" : cell.char}\n \n ))}\n \n );\n}\n", "type": "registry:ui", "target": "components/ruixen/scramble-text.tsx" } ] }