|
| 1 | +import { useEffect, useState, useRef, ReactNode } from 'react' |
| 2 | +import { motion, HTMLMotionProps } from 'motion/react' |
| 3 | + |
| 4 | +const styles = { |
| 5 | + wrapper: { |
| 6 | + display: 'inline-block', |
| 7 | + whiteSpace: 'pre-wrap', |
| 8 | + }, |
| 9 | + srOnly: { |
| 10 | + position: 'absolute' as 'absolute', |
| 11 | + width: '1px', |
| 12 | + height: '1px', |
| 13 | + padding: 0, |
| 14 | + margin: '-1px', |
| 15 | + overflow: 'hidden', |
| 16 | + clip: 'rect(0,0,0,0)', |
| 17 | + border: 0, |
| 18 | + }, |
| 19 | +} |
| 20 | + |
| 21 | +interface DecryptedTextProps extends HTMLMotionProps<'span'> { |
| 22 | + text: string |
| 23 | + speed?: number |
| 24 | + maxIterations?: number |
| 25 | + sequential?: boolean |
| 26 | + revealDirection?: 'start' | 'end' | 'center' |
| 27 | + useOriginalCharsOnly?: boolean |
| 28 | + characters?: string |
| 29 | + className?: string |
| 30 | + parentClassName?: string |
| 31 | + encryptedClassName?: string |
| 32 | + animateOn?: 'view' | 'hover' |
| 33 | +} |
| 34 | + |
| 35 | +export default function DecryptedText({ |
| 36 | + text, |
| 37 | + speed = 50, |
| 38 | + maxIterations = 10, |
| 39 | + sequential = false, |
| 40 | + revealDirection = 'start', |
| 41 | + useOriginalCharsOnly = false, |
| 42 | + characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!@#$%^&*()_+', |
| 43 | + className = '', |
| 44 | + parentClassName = '', |
| 45 | + encryptedClassName = '', |
| 46 | + animateOn = 'hover', |
| 47 | + ...props |
| 48 | +}: DecryptedTextProps) { |
| 49 | + const [displayText, setDisplayText] = useState<string>(text) |
| 50 | + const [isHovering, setIsHovering] = useState<boolean>(false) |
| 51 | + const [isScrambling, setIsScrambling] = useState<boolean>(false) |
| 52 | + const [revealedIndices, setRevealedIndices] = useState<Set<number>>(new Set()) |
| 53 | + const [hasAnimated, setHasAnimated] = useState<boolean>(false) |
| 54 | + const containerRef = useRef<HTMLSpanElement>(null) |
| 55 | + |
| 56 | + useEffect(() => { |
| 57 | + let interval: NodeJS.Timeout; |
| 58 | + let currentIteration = 0 |
| 59 | + |
| 60 | + const getNextIndex = (revealedSet: Set<number>): number => { |
| 61 | + const textLength = text.length |
| 62 | + switch (revealDirection) { |
| 63 | + case 'start': |
| 64 | + return revealedSet.size |
| 65 | + case 'end': |
| 66 | + return textLength - 1 - revealedSet.size |
| 67 | + case 'center': { |
| 68 | + const middle = Math.floor(textLength / 2) |
| 69 | + const offset = Math.floor(revealedSet.size / 2) |
| 70 | + const nextIndex = |
| 71 | + revealedSet.size % 2 === 0 |
| 72 | + ? middle + offset |
| 73 | + : middle - offset - 1 |
| 74 | + |
| 75 | + if (nextIndex >= 0 && nextIndex < textLength && !revealedSet.has(nextIndex)) { |
| 76 | + return nextIndex |
| 77 | + } |
| 78 | + |
| 79 | + for (let i = 0; i < textLength; i++) { |
| 80 | + if (!revealedSet.has(i)) return i |
| 81 | + } |
| 82 | + return 0 |
| 83 | + } |
| 84 | + default: |
| 85 | + return revealedSet.size |
| 86 | + } |
| 87 | + } |
| 88 | + |
| 89 | + const availableChars = useOriginalCharsOnly |
| 90 | + ? Array.from(new Set(text.split(''))).filter((char) => char !== ' ') |
| 91 | + : characters.split('') |
| 92 | + |
| 93 | + const shuffleText = (originalText: string, currentRevealed: Set<number>): string => { |
| 94 | + if (useOriginalCharsOnly) { |
| 95 | + const positions = originalText.split('').map((char, i) => ({ |
| 96 | + char, |
| 97 | + isSpace: char === ' ', |
| 98 | + index: i, |
| 99 | + isRevealed: currentRevealed.has(i), |
| 100 | + })) |
| 101 | + |
| 102 | + const nonSpaceChars = positions |
| 103 | + .filter((p) => !p.isSpace && !p.isRevealed) |
| 104 | + .map((p) => p.char) |
| 105 | + |
| 106 | + for (let i = nonSpaceChars.length - 1; i > 0; i--) { |
| 107 | + const j = Math.floor(Math.random() * (i + 1)) |
| 108 | + ;[nonSpaceChars[i], nonSpaceChars[j]] = [nonSpaceChars[j], nonSpaceChars[i]] |
| 109 | + } |
| 110 | + |
| 111 | + let charIndex = 0 |
| 112 | + return positions |
| 113 | + .map((p) => { |
| 114 | + if (p.isSpace) return ' ' |
| 115 | + if (p.isRevealed) return originalText[p.index] |
| 116 | + return nonSpaceChars[charIndex++] |
| 117 | + }) |
| 118 | + .join('') |
| 119 | + } else { |
| 120 | + return originalText |
| 121 | + .split('') |
| 122 | + .map((char, i) => { |
| 123 | + if (char === ' ') return ' ' |
| 124 | + if (currentRevealed.has(i)) return originalText[i] |
| 125 | + return availableChars[Math.floor(Math.random() * availableChars.length)] |
| 126 | + }) |
| 127 | + .join('') |
| 128 | + } |
| 129 | + } |
| 130 | + |
| 131 | + if (isHovering) { |
| 132 | + setIsScrambling(true) |
| 133 | + interval = setInterval(() => { |
| 134 | + setRevealedIndices((prevRevealed) => { |
| 135 | + if (sequential) { |
| 136 | + if (prevRevealed.size < text.length) { |
| 137 | + const nextIndex = getNextIndex(prevRevealed) |
| 138 | + const newRevealed = new Set(prevRevealed) |
| 139 | + newRevealed.add(nextIndex) |
| 140 | + setDisplayText(shuffleText(text, newRevealed)) |
| 141 | + return newRevealed |
| 142 | + } else { |
| 143 | + clearInterval(interval) |
| 144 | + setIsScrambling(false) |
| 145 | + return prevRevealed |
| 146 | + } |
| 147 | + } else { |
| 148 | + setDisplayText(shuffleText(text, prevRevealed)) |
| 149 | + currentIteration++ |
| 150 | + if (currentIteration >= maxIterations) { |
| 151 | + clearInterval(interval) |
| 152 | + setIsScrambling(false) |
| 153 | + setDisplayText(text) |
| 154 | + } |
| 155 | + return prevRevealed |
| 156 | + } |
| 157 | + }) |
| 158 | + }, speed) |
| 159 | + } else { |
| 160 | + setDisplayText(text) |
| 161 | + setRevealedIndices(new Set()) |
| 162 | + setIsScrambling(false) |
| 163 | + } |
| 164 | + |
| 165 | + return () => { |
| 166 | + if (interval) clearInterval(interval) |
| 167 | + } |
| 168 | + }, [ |
| 169 | + isHovering, |
| 170 | + text, |
| 171 | + speed, |
| 172 | + maxIterations, |
| 173 | + sequential, |
| 174 | + revealDirection, |
| 175 | + characters, |
| 176 | + useOriginalCharsOnly, |
| 177 | + ]) |
| 178 | + |
| 179 | + useEffect(() => { |
| 180 | + if (animateOn !== 'view') return |
| 181 | + |
| 182 | + const observerCallback = (entries: IntersectionObserverEntry[]) => { |
| 183 | + entries.forEach((entry) => { |
| 184 | + if (entry.isIntersecting && !hasAnimated) { |
| 185 | + setIsHovering(true) |
| 186 | + setHasAnimated(true) |
| 187 | + } |
| 188 | + }) |
| 189 | + } |
| 190 | + |
| 191 | + const observerOptions = { |
| 192 | + root: null, |
| 193 | + rootMargin: '0px', |
| 194 | + threshold: 0.1, |
| 195 | + } |
| 196 | + |
| 197 | + const observer = new IntersectionObserver(observerCallback, observerOptions) |
| 198 | + const currentRef = containerRef.current |
| 199 | + if (currentRef) { |
| 200 | + observer.observe(currentRef) |
| 201 | + } |
| 202 | + |
| 203 | + return () => { |
| 204 | + if (currentRef) { |
| 205 | + observer.unobserve(currentRef) |
| 206 | + } |
| 207 | + } |
| 208 | + }, [animateOn, hasAnimated]) |
| 209 | + |
| 210 | + const hoverProps = |
| 211 | + animateOn === 'hover' |
| 212 | + ? { |
| 213 | + onMouseEnter: () => setIsHovering(true), |
| 214 | + onMouseLeave: () => setIsHovering(false), |
| 215 | + } |
| 216 | + : {} |
| 217 | + |
| 218 | + return ( |
| 219 | + <motion.span className={parentClassName} ref={containerRef} style={styles.wrapper} {...hoverProps} {...props}> |
| 220 | + <span style={styles.srOnly}>{displayText}</span> |
| 221 | + |
| 222 | + <span aria-hidden="true"> |
| 223 | + {displayText.split('').map((char, index) => { |
| 224 | + const isRevealedOrDone = |
| 225 | + revealedIndices.has(index) || !isScrambling || !isHovering |
| 226 | + |
| 227 | + return ( |
| 228 | + <span |
| 229 | + key={index} |
| 230 | + className={isRevealedOrDone ? className : encryptedClassName} |
| 231 | + > |
| 232 | + {char} |
| 233 | + </span> |
| 234 | + ) |
| 235 | + })} |
| 236 | + </span> |
| 237 | + </motion.span> |
| 238 | + ) |
| 239 | +} |
0 commit comments