{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "decrypted-text", "title": "Decrypted Text", "description": "A text component with decoding animation—random katakana glyphs gradually resolve to reveal the target text. | 片仮名グリフが徐々に目標テキストへ解碼するアニメーション付きテキストコンポーネント。遅延設定・動きの軽減対応。", "dependencies": [ "motion" ], "files": [ { "path": "components/text/decrypted-text.tsx", "content": "\"use client\";\n\nimport {\n createContext,\n use,\n useEffect,\n useMemo,\n useRef,\n useState,\n type RefObject,\n} from \"react\";\nimport { motion, useReducedMotion, useSpring } from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\n// --- Helpers (outside component for stable refs) ---\n\nfunction getNextIndex(\n revealedSet: Set,\n textLength: number,\n revealDirection: \"start\" | \"end\" | \"center\"\n): number {\n switch (revealDirection) {\n case \"start\":\n return revealedSet.size;\n case \"end\":\n return textLength - 1 - revealedSet.size;\n case \"center\": {\n const middle = Math.floor(textLength / 2);\n const offset = Math.floor(revealedSet.size / 2);\n const nextIndex =\n revealedSet.size % 2 === 0 ? middle + offset : middle - offset - 1;\n\n if (\n nextIndex >= 0 &&\n nextIndex < textLength &&\n !revealedSet.has(nextIndex)\n ) {\n return nextIndex;\n }\n for (let i = 0; i < textLength; i++) {\n if (!revealedSet.has(i)) return i;\n }\n return 0;\n }\n default:\n return revealedSet.size;\n }\n}\n\nfunction shuffleText(\n originalText: string,\n currentRevealed: Set,\n availableChars: string[],\n useOriginalCharsOnly: boolean\n): string {\n if (useOriginalCharsOnly) {\n const positions = originalText.split(\"\").map((char, i) => ({\n char,\n isSpace: char === \" \",\n index: i,\n isRevealed: currentRevealed.has(i),\n }));\n\n const nonSpaceChars = positions\n .filter((p) => !p.isSpace && !p.isRevealed)\n .map((p) => p.char);\n\n for (let i = nonSpaceChars.length - 1; i > 0; i--) {\n const j = Math.floor(Math.random() * (i + 1));\n [nonSpaceChars[i], nonSpaceChars[j]] = [\n nonSpaceChars[j],\n nonSpaceChars[i],\n ];\n }\n\n let charIndex = 0;\n return positions\n .map((p) => {\n if (p.isSpace) return \" \";\n if (p.isRevealed) return originalText[p.index];\n return nonSpaceChars[charIndex++];\n })\n .join(\"\");\n } else {\n return originalText\n .split(\"\")\n .map((char, i) => {\n if (char === \" \") return \" \";\n if (currentRevealed.has(i)) return originalText[i];\n return availableChars[\n Math.floor(Math.random() * availableChars.length)\n ];\n })\n .join(\"\");\n }\n}\n\n// --- Context ---\n\ninterface DecryptedTextContextValue {\n state: {\n isHovering: boolean;\n isScrambling: boolean;\n displayText: string;\n };\n actions: {\n setHovering: (v: boolean) => void;\n };\n meta: {\n containerRef: RefObject;\n text: string;\n className: string;\n encryptedClassName: string;\n };\n}\n\nconst DecryptedTextContext =\n createContext(null);\n\n// --- Provider ---\n\n/**\n * Holds config and internal animation state. Only place that knows implementation\n * details (spring vs interval, refs vs state). Use for compound composition.\n */\ninterface DecryptedTextProviderProps {\n /** The final text to reveal after animation */\n text: string;\n /** Interval in ms between scramble updates (non-sequential mode) */\n speed?: number;\n /** Max shuffle cycles before revealing (non-sequential) */\n maxIterations?: number;\n /** Reveal one character at a time vs scramble then reveal all */\n sequential?: boolean;\n /** Order of reveal: start, end, or center */\n revealDirection?: \"start\" | \"end\" | \"center\";\n /** Use only chars from text for scramble */\n useOriginalCharsOnly?: boolean;\n /** Custom character set for scramble */\n characters?: string;\n /** Styles for revealed characters */\n className?: string;\n /** Styles for scrambled characters */\n encryptedClassName?: string;\n /** Wrapper element styles */\n parentClassName?: string;\n children: React.ReactNode;\n}\n\nfunction DecryptedTextProvider({\n text,\n speed = 50,\n maxIterations = 10,\n sequential = false,\n revealDirection = \"start\",\n useOriginalCharsOnly = false,\n characters = \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!@#$%^&*()_+\",\n className = \"\",\n parentClassName = \"\",\n encryptedClassName = \"\",\n children,\n}: DecryptedTextProviderProps) {\n const reduceMotion = useReducedMotion();\n const containerRef = useRef(null);\n const [isHovering, setIsHovering] = useState(false);\n const [isScrambling, setIsScrambling] = useState(false);\n const [displayText, setDisplayText] = useState(text);\n\n const availableChars = useMemo(\n () =>\n useOriginalCharsOnly\n ? Array.from(new Set(text.split(\"\"))).filter((char) => char !== \" \")\n : characters.split(\"\"),\n [text, characters, useOriginalCharsOnly]\n );\n\n const decoderSpring = useSpring(0, { stiffness: 8, damping: 4 });\n const outputRef = useRef(text);\n const revealedRef = useRef>(new Set());\n const maxPositionRef = useRef(0);\n const cancelledRef = useRef(false);\n const currentIterationRef = useRef(0);\n const content = text.split(\"\");\n\n const renderOutput = (str: string, revealed: Set) => {\n if (!containerRef.current) return;\n const revealedClassName = cn(className);\n const scrambledClassName = cn(encryptedClassName);\n const html = str\n .split(\"\")\n .map(\n (char, i) =>\n `${char}`\n )\n .join(\"\");\n containerRef.current.innerHTML = html;\n };\n\n useEffect(() => {\n cancelledRef.current = false;\n maxPositionRef.current = 0;\n currentIterationRef.current = 0;\n revealedRef.current = new Set();\n outputRef.current = text;\n setDisplayText(text);\n setIsScrambling(false);\n setIsHovering(false);\n }, [text]);\n\n useEffect(() => {\n if (reduceMotion) {\n outputRef.current = text;\n setDisplayText(text);\n setIsScrambling(false);\n renderOutput(text, new Set(text.split(\"\").map((_, i) => i)));\n return;\n }\n\n if (!isHovering) {\n outputRef.current = text;\n setDisplayText(text);\n setIsScrambling(false);\n revealedRef.current = new Set();\n renderOutput(text, new Set(text.split(\"\").map((_, i) => i)));\n return;\n }\n\n if (sequential) {\n cancelledRef.current = false;\n maxPositionRef.current = 0;\n setIsScrambling(true);\n const unsubscribe = decoderSpring.on(\"change\", (value: number) => {\n if (cancelledRef.current) return;\n const rawPos = Math.floor(value);\n const pos = Math.min(content.length, Math.max(0, rawPos));\n maxPositionRef.current = Math.max(maxPositionRef.current, pos);\n\n const orderedRevealed = new Set();\n for (let i = 0; i < maxPositionRef.current; i++) {\n orderedRevealed.add(\n getNextIndex(orderedRevealed, content.length, revealDirection)\n );\n }\n\n const next = shuffleText(\n text,\n orderedRevealed,\n availableChars,\n useOriginalCharsOnly\n );\n outputRef.current = next;\n revealedRef.current = orderedRevealed;\n renderOutput(next, orderedRevealed);\n });\n\n decoderSpring.jump(0);\n decoderSpring.set(content.length);\n\n return () => {\n cancelledRef.current = true;\n unsubscribe();\n };\n }\n\n cancelledRef.current = false;\n currentIterationRef.current = 0;\n setIsScrambling(true);\n let interval: NodeJS.Timeout;\n interval = setInterval(() => {\n if (cancelledRef.current) return;\n\n currentIterationRef.current += 1;\n const prevRevealed = revealedRef.current;\n\n if (currentIterationRef.current >= maxIterations) {\n if (interval) clearInterval(interval);\n setIsScrambling(false);\n outputRef.current = text;\n setDisplayText(text);\n renderOutput(text, new Set(text.split(\"\").map((_, i) => i)));\n return;\n }\n\n const next = shuffleText(text, prevRevealed, availableChars, useOriginalCharsOnly);\n outputRef.current = next;\n renderOutput(next, prevRevealed);\n }, speed);\n\n return () => {\n cancelledRef.current = true;\n if (interval) clearInterval(interval);\n };\n }, [\n isHovering,\n text,\n speed,\n maxIterations,\n sequential,\n revealDirection,\n useOriginalCharsOnly,\n reduceMotion,\n decoderSpring,\n content.length,\n availableChars,\n ]);\n\n const contextValue: DecryptedTextContextValue = {\n state: { isHovering, isScrambling, displayText },\n actions: { setHovering: setIsHovering },\n meta: {\n containerRef,\n text,\n className,\n encryptedClassName,\n },\n };\n\n return (\n \n {children}\n \n );\n}\n\n// --- Compound Components ---\n\n/** Wraps content and handles hover or view-based trigger */\nfunction DecryptedTextRoot({\n children,\n className,\n animateOn = \"hover\",\n ...props\n}: {\n children: React.ReactNode;\n className?: string;\n animateOn?: \"view\" | \"hover\";\n [key: string]: unknown;\n}) {\n const ctx = use(DecryptedTextContext);\n if (!ctx) throw new Error(\"DecryptedText.Root must be inside DecryptedText.Provider\");\n\n if (animateOn === \"hover\") {\n return (\n \n {children}\n \n );\n }\n return (\n \n {children}\n \n );\n}\n\n/** Wraps Root with onMouseEnter / onMouseLeave for hover trigger */\nfunction DecryptedTextHoverRoot({\n children,\n className,\n parentClassName,\n ...props\n}: {\n children: React.ReactNode;\n className?: string;\n parentClassName?: string;\n [key: string]: unknown;\n}) {\n const ctx = use(DecryptedTextContext);\n if (!ctx) throw new Error(\"DecryptedText.HoverRoot must be inside DecryptedText.Provider\");\n\n return (\n ctx.actions.setHovering(true)}\n onMouseLeave={() => ctx.actions.setHovering(false)}\n {...props}\n >\n {children}\n \n );\n}\n\n/** Wraps Root with IntersectionObserver; triggers animation when in view */\nfunction DecryptedTextViewRoot({\n children,\n className,\n parentClassName,\n ...props\n}: {\n children: React.ReactNode;\n className?: string;\n parentClassName?: string;\n [key: string]: unknown;\n}) {\n const ctx = use(DecryptedTextContext);\n const [hasAnimated, setHasAnimated] = useState(false);\n const rootRef = useRef(null);\n\n if (!ctx) throw new Error(\"DecryptedText.ViewRoot must be inside DecryptedText.Provider\");\n\n useEffect(() => {\n setHasAnimated(false);\n }, [ctx.meta.text]);\n\n useEffect(() => {\n if (hasAnimated) return;\n const el = rootRef.current;\n if (!el) return;\n\n const observer = new IntersectionObserver(\n (entries) => {\n entries.forEach((entry) => {\n if (entry.isIntersecting) {\n ctx.actions.setHovering(true);\n setHasAnimated(true);\n }\n });\n },\n { root: null, rootMargin: \"0px\", threshold: 0.1 }\n );\n observer.observe(el);\n return () => observer.disconnect();\n }, [ctx.actions, hasAnimated]);\n\n return (\n \n {children}\n \n );\n}\n\n/** Renders the scrambled/revealed character grid via ref and innerHTML */\nfunction DecryptedTextContent() {\n const ctx = use(DecryptedTextContext);\n if (!ctx) throw new Error(\"DecryptedText.Content must be inside DecryptedText.Provider\");\n\n return (\n <>\n {ctx.state.displayText}\n } />\n \n );\n}\n\n// --- Main Component ---\n\n/**\n * Scrambled text that reveals on hover or when in view.\n * Uses ref-based DOM updates for performance; supports sequential and batch modes.\n */\ninterface DecryptedTextProps {\n /** The final text to reveal after animation */\n text: string;\n /** Interval in ms between scramble updates (non-sequential mode) */\n speed?: number;\n /** Max shuffle cycles before revealing (non-sequential) */\n maxIterations?: number;\n /** Reveal one character at a time vs scramble then reveal all */\n sequential?: boolean;\n /** Order of reveal: start, end, or center */\n revealDirection?: \"start\" | \"end\" | \"center\";\n /** Use only chars from text for scramble */\n useOriginalCharsOnly?: boolean;\n /** Custom character set for scramble */\n characters?: string;\n /** Styles for revealed characters */\n className?: string;\n /** Styles for scrambled characters */\n encryptedClassName?: string;\n /** Wrapper element styles */\n parentClassName?: string;\n /** Trigger: hover or when in view */\n animateOn?: \"view\" | \"hover\";\n [key: string]: unknown;\n}\n\n/** Default export: convenience wrapper that composes Provider, Root, and Content. */\nexport default function DecryptedText({\n text,\n speed = 50,\n maxIterations = 10,\n sequential = false,\n revealDirection = \"start\",\n useOriginalCharsOnly = false,\n characters = \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!@#$%^&*()_+\",\n className = \"\",\n parentClassName = \"\",\n encryptedClassName = \"\",\n animateOn = \"hover\",\n ...props\n}: DecryptedTextProps) {\n return (\n \n \n \n \n \n );\n}\n\nDecryptedText.Provider = DecryptedTextProvider;\nDecryptedText.Root = DecryptedTextRoot;\nDecryptedText.HoverRoot = DecryptedTextHoverRoot;\nDecryptedText.ViewRoot = DecryptedTextViewRoot;\nDecryptedText.Content = DecryptedTextContent;\n", "type": "registry:ui" } ], "type": "registry:ui" }