{
	"$schema": "https://ui.shadcn.com/schema/registry-item.json",
	"name": "DecryptedText-JS-CSS",
	"title": "DecryptedText",
	"description": "Hacker-style decryption cycling random glyphs until resolving to real text.",
	"type": "registry:component",
	"files": [
		{
			"type": "registry:component",
			"path": "DecryptedText/DecryptedText.jsx",
			"content": "import { useEffect, useState, useRef, useMemo, useCallback } from 'react';\nimport { motion } from 'motion/react';\n\nconst styles = {\n  wrapper: {\n    display: 'inline-block',\n    whiteSpace: 'pre-wrap'\n  },\n  srOnly: {\n    position: 'absolute',\n    width: '1px',\n    height: '1px',\n    padding: 0,\n    margin: '-1px',\n    overflow: 'hidden',\n    clip: 'rect(0,0,0,0)',\n    border: 0\n  }\n};\n\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  clickMode = 'once',\n  ...props\n}) {\n  const [displayText, setDisplayText] = useState(text);\n  const [isAnimating, setIsAnimating] = useState(false);\n  const [revealedIndices, setRevealedIndices] = useState(new Set());\n  const [hasAnimated, setHasAnimated] = useState(false);\n  const [isDecrypted, setIsDecrypted] = useState(animateOn !== 'click');\n  const [direction, setDirection] = useState('forward');\n\n  const containerRef = useRef(null);\n  const orderRef = useRef([]);\n  const pointerRef = useRef(0);\n  const intervalRef = useRef(null);\n\n  const availableChars = useMemo(() => {\n    return useOriginalCharsOnly\n      ? Array.from(new Set(text.split(''))).filter(char => char !== ' ')\n      : characters.split('');\n  }, [useOriginalCharsOnly, text, characters]);\n\n  const shuffleText = useCallback(\n    (originalText, currentRevealed) => {\n      return originalText\n        .split('')\n        .map((char, i) => {\n          if (char === ' ') return ' ';\n          if (currentRevealed.has(i)) return originalText[i];\n          return availableChars[Math.floor(Math.random() * availableChars.length)];\n        })\n        .join('');\n    },\n    [availableChars]\n  );\n\n  const computeOrder = useCallback(\n    len => {\n      const order = [];\n      if (len <= 0) return order;\n      if (revealDirection === 'start') {\n        for (let i = 0; i < len; i++) order.push(i);\n        return order;\n      }\n      if (revealDirection === 'end') {\n        for (let i = len - 1; i >= 0; i--) order.push(i);\n        return order;\n      }\n      // center\n      const middle = Math.floor(len / 2);\n      let offset = 0;\n      while (order.length < len) {\n        if (offset % 2 === 0) {\n          const idx = middle + offset / 2;\n          if (idx >= 0 && idx < len) order.push(idx);\n        } else {\n          const idx = middle - Math.ceil(offset / 2);\n          if (idx >= 0 && idx < len) order.push(idx);\n        }\n        offset++;\n      }\n      return order.slice(0, len);\n    },\n    [revealDirection]\n  );\n\n  const fillAllIndices = useCallback(() => {\n    const s = new Set();\n    for (let i = 0; i < text.length; i++) s.add(i);\n    return s;\n  }, [text]);\n\n  const removeRandomIndices = useCallback((set, count) => {\n    const arr = Array.from(set);\n    for (let i = 0; i < count && arr.length > 0; i++) {\n      const idx = Math.floor(Math.random() * arr.length);\n      arr.splice(idx, 1);\n    }\n    return new Set(arr);\n  }, []);\n\n  const encryptInstantly = useCallback(() => {\n    const emptySet = new Set();\n    setRevealedIndices(emptySet);\n    setDisplayText(shuffleText(text, emptySet));\n    setIsDecrypted(false);\n  }, [text, shuffleText]);\n\n  const triggerDecrypt = useCallback(() => {\n    if (sequential) {\n      orderRef.current = computeOrder(text.length);\n      pointerRef.current = 0;\n      setRevealedIndices(new Set());\n    } else {\n      setRevealedIndices(new Set());\n    }\n    setDirection('forward');\n    setIsAnimating(true);\n  }, [sequential, computeOrder, text.length]);\n\n  const triggerReverse = useCallback(() => {\n    if (sequential) {\n      // compute forward order then reverse it: we'll remove indices in that order\n      orderRef.current = computeOrder(text.length).slice().reverse();\n      pointerRef.current = 0;\n      setRevealedIndices(fillAllIndices()); // start fully revealed\n      setDisplayText(shuffleText(text, fillAllIndices()));\n    } else {\n      // non-seq: start from fully revealed as well\n      setRevealedIndices(fillAllIndices());\n      setDisplayText(shuffleText(text, fillAllIndices()));\n    }\n    setDirection('reverse');\n    setIsAnimating(true);\n  }, [sequential, computeOrder, fillAllIndices, shuffleText, text]);\n\n  useEffect(() => {\n    if (!isAnimating) return;\n\n    let currentIteration = 0;\n\n    const getNextIndex = revealedSet => {\n      const textLength = text.length;\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 = revealedSet.size % 2 === 0 ? middle + offset : middle - offset - 1;\n\n          if (nextIndex >= 0 && nextIndex < textLength && !revealedSet.has(nextIndex)) {\n            return nextIndex;\n          }\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\n    intervalRef.current = setInterval(() => {\n      setRevealedIndices(prevRevealed => {\n        if (sequential) {\n          // Forward\n          if (direction === 'forward') {\n            if (prevRevealed.size < text.length) {\n              const nextIndex = getNextIndex(prevRevealed);\n              const newRevealed = new Set(prevRevealed);\n              newRevealed.add(nextIndex);\n              setDisplayText(shuffleText(text, newRevealed));\n              return newRevealed;\n            } else {\n              clearInterval(intervalRef.current);\n              setIsAnimating(false);\n              setIsDecrypted(true);\n              return prevRevealed;\n            }\n          }\n          // Reverse\n          if (direction === 'reverse') {\n            if (pointerRef.current < orderRef.current.length) {\n              const idxToRemove = orderRef.current[pointerRef.current++];\n              const newRevealed = new Set(prevRevealed);\n              newRevealed.delete(idxToRemove);\n              setDisplayText(shuffleText(text, newRevealed));\n              if (newRevealed.size === 0) {\n                clearInterval(intervalRef.current);\n                setIsAnimating(false);\n                setIsDecrypted(false);\n              }\n              return newRevealed;\n            } else {\n              clearInterval(intervalRef.current);\n              setIsAnimating(false);\n              setIsDecrypted(false);\n              return prevRevealed;\n            }\n          }\n        } else {\n          // Non-Sequential\n          if (direction === 'forward') {\n            setDisplayText(shuffleText(text, prevRevealed));\n            currentIteration++;\n            if (currentIteration >= maxIterations) {\n              clearInterval(intervalRef.current);\n              setIsAnimating(false);\n              setDisplayText(text);\n              setIsDecrypted(true);\n            }\n            return prevRevealed;\n          }\n\n          // Non-Sequential Reverse\n          if (direction === 'reverse') {\n            let currentSet = prevRevealed;\n            if (currentSet.size === 0) {\n              currentSet = fillAllIndices();\n            }\n            const removeCount = Math.max(1, Math.ceil(text.length / Math.max(1, maxIterations)));\n            const nextSet = removeRandomIndices(currentSet, removeCount);\n            setDisplayText(shuffleText(text, nextSet));\n            currentIteration++;\n            if (nextSet.size === 0 || currentIteration >= maxIterations) {\n              clearInterval(intervalRef.current);\n              setIsAnimating(false);\n              setIsDecrypted(false);\n              // ensure final scrambled state\n              setDisplayText(shuffleText(text, new Set()));\n              return new Set();\n            }\n            return nextSet;\n          }\n        }\n        return prevRevealed;\n      });\n    }, speed);\n\n    return () => clearInterval(intervalRef.current);\n  }, [\n    isAnimating,\n    text,\n    speed,\n    maxIterations,\n    sequential,\n    revealDirection,\n    shuffleText,\n    direction,\n    fillAllIndices,\n    removeRandomIndices,\n    characters,\n    useOriginalCharsOnly\n  ]);\n\n  /* Click Behaviour */\n  const handleClick = () => {\n    if (animateOn !== 'click') return;\n\n    if (clickMode === 'once') {\n      if (isDecrypted) return;\n      setDirection('forward');\n      triggerDecrypt();\n    }\n\n    if (clickMode === 'toggle') {\n      if (isDecrypted) {\n        triggerReverse();\n      } else {\n        setDirection('forward');\n        triggerDecrypt();\n      }\n    }\n  };\n\n  /* Hover Behaviour */\n  const triggerHoverDecrypt = useCallback(() => {\n    if (isAnimating) return;\n\n    setRevealedIndices(new Set());\n    setIsDecrypted(false);\n    setDisplayText(text);\n    setDirection('forward');\n    setIsAnimating(true);\n  }, [isAnimating, text]);\n\n  const resetToPlainText = useCallback(() => {\n    clearInterval(intervalRef.current);\n    setIsAnimating(false);\n    setRevealedIndices(new Set());\n    setDisplayText(text);\n    setIsDecrypted(true);\n    setDirection('forward');\n  }, [text]);\n\n  /* View Observer */\n  useEffect(() => {\n    if (animateOn !== 'view' && animateOn !== 'inViewHover') return;\n\n    const observerCallback = entries => {\n      entries.forEach(entry => {\n        if (entry.isIntersecting && !hasAnimated) {\n          triggerDecrypt();\n          setHasAnimated(true);\n        }\n      });\n    };\n\n    const observerOptions = {\n      root: null,\n      rootMargin: '0px',\n      threshold: 0.1\n    };\n\n    const observer = new IntersectionObserver(observerCallback, observerOptions);\n    const currentRef = containerRef.current;\n    if (currentRef) {\n      observer.observe(currentRef);\n    }\n\n    return () => {\n      if (currentRef) {\n        observer.unobserve(currentRef);\n      }\n    };\n  }, [animateOn, hasAnimated, triggerDecrypt]);\n\n  useEffect(() => {\n    if (animateOn === 'click') {\n      encryptInstantly();\n    } else {\n      setDisplayText(text);\n      setIsDecrypted(true);\n    }\n    setRevealedIndices(new Set());\n    setDirection('forward');\n  }, [animateOn, text, encryptInstantly]);\n\n  const animateProps =\n    animateOn === 'hover' || animateOn === 'inViewHover'\n      ? {\n          onMouseEnter: triggerHoverDecrypt,\n          onMouseLeave: resetToPlainText\n        }\n      : animateOn === 'click'\n        ? {\n            onClick: handleClick\n          }\n        : {};\n\n  return (\n    <motion.span className={parentClassName} ref={containerRef} style={styles.wrapper} {...animateProps} {...props}>\n      <span style={styles.srOnly}>{displayText}</span>\n\n      <span aria-hidden=\"true\">\n        {displayText.split('').map((char, index) => {\n          const isRevealedOrDone = revealedIndices.has(index) || (!isAnimating && isDecrypted);\n\n          return (\n            <span key={index} className={isRevealedOrDone ? className : encryptedClassName}>\n              {char}\n            </span>\n          );\n        })}\n      </span>\n    </motion.span>\n  );\n}\n"
		}
	],
	"registryDependencies": [],
	"dependencies": [
		"motion@^12.23.12"
	]
}