+{/* Example 3: Click to decrypt (toggle mode) */}
+
+{/* Example 4: Animate on view (runs once) */}
+
+
+ />
`,
code,
tailwind,
diff --git a/src/content/TextAnimations/DecryptedText/DecryptedText.jsx b/src/content/TextAnimations/DecryptedText/DecryptedText.jsx
index bc611dac..6eab473b 100644
--- a/src/content/TextAnimations/DecryptedText/DecryptedText.jsx
+++ b/src/content/TextAnimations/DecryptedText/DecryptedText.jsx
@@ -1,4 +1,4 @@
-import { useEffect, useState, useRef } from 'react';
+import { useEffect, useState, useRef, useMemo, useCallback } from 'react';
import { motion } from 'motion/react';
const styles = {
@@ -30,16 +30,123 @@ export default function DecryptedText({
parentClassName = '',
encryptedClassName = '',
animateOn = 'hover',
+ clickMode = 'once',
...props
}) {
const [displayText, setDisplayText] = useState(text);
- const [isHovering, setIsHovering] = useState(false);
- const [isScrambling, setIsScrambling] = useState(false);
+ const [isAnimating, setIsAnimating] = useState(false);
const [revealedIndices, setRevealedIndices] = useState(new Set());
const [hasAnimated, setHasAnimated] = useState(false);
+ const [isDecrypted, setIsDecrypted] = useState(animateOn !== 'click');
+ const [direction, setDirection] = useState('forward');
+
const containerRef = useRef(null);
+ const orderRef = useRef([]);
+ const pointerRef = useRef(0);
+
+ const availableChars = useMemo(() => {
+ return useOriginalCharsOnly
+ ? Array.from(new Set(text.split(''))).filter(char => char !== ' ')
+ : characters.split('');
+ }, [useOriginalCharsOnly, text, characters]);
+
+ const shuffleText = useCallback(
+ (originalText, currentRevealed) => {
+ return originalText
+ .split('')
+ .map((char, i) => {
+ if (char === ' ') return ' ';
+ if (currentRevealed.has(i)) return originalText[i];
+ return availableChars[Math.floor(Math.random() * availableChars.length)];
+ })
+ .join('');
+ },
+ [availableChars]
+ );
+
+ const computeOrder = useCallback(
+ len => {
+ const order = [];
+ if (len <= 0) return order;
+ if (revealDirection === 'start') {
+ for (let i = 0; i < len; i++) order.push(i);
+ return order;
+ }
+ if (revealDirection === 'end') {
+ for (let i = len - 1; i >= 0; i--) order.push(i);
+ return order;
+ }
+ // center
+ const middle = Math.floor(len / 2);
+ let offset = 0;
+ while (order.length < len) {
+ if (offset % 2 === 0) {
+ const idx = middle + offset / 2;
+ if (idx >= 0 && idx < len) order.push(idx);
+ } else {
+ const idx = middle - Math.ceil(offset / 2);
+ if (idx >= 0 && idx < len) order.push(idx);
+ }
+ offset++;
+ }
+ return order.slice(0, len);
+ },
+ [revealDirection]
+ );
+
+ const fillAllIndices = useCallback(() => {
+ const s = new Set();
+ for (let i = 0; i < text.length; i++) s.add(i);
+ return s;
+ }, [text]);
+
+ const removeRandomIndices = useCallback((set, count) => {
+ const arr = Array.from(set);
+ for (let i = 0; i < count && arr.length > 0; i++) {
+ const idx = Math.floor(Math.random() * arr.length);
+ arr.splice(idx, 1);
+ }
+ return new Set(arr);
+ }, []);
+
+ const encryptInstantly = useCallback(() => {
+ const emptySet = new Set();
+ setRevealedIndices(emptySet);
+ setDisplayText(shuffleText(text, emptySet));
+ setIsDecrypted(false);
+ }, [text, shuffleText]);
+
+ const triggerDecrypt = useCallback(() => {
+ if (sequential) {
+ orderRef.current = computeOrder(text.length);
+ pointerRef.current = 0;
+ setRevealedIndices(new Set());
+ } else {
+ setRevealedIndices(new Set());
+ }
+ setDirection('forward');
+ setIsAnimating(true);
+ }, [sequential, computeOrder, text.length]);
+
+ const triggerReverse = useCallback(() => {
+ if (sequential) {
+ // compute forward order then reverse it: we'll remove indices in that order
+ orderRef.current = computeOrder(text.length).slice().reverse();
+ pointerRef.current = 0;
+ setRevealedIndices(fillAllIndices()); // start fully revealed
+ setDisplayText(shuffleText(text, fillAllIndices()));
+ } else {
+ // non-seq: start from fully revealed as well
+ setRevealedIndices(fillAllIndices());
+ setDisplayText(shuffleText(text, fillAllIndices()));
+ }
+ setDirection('reverse');
+ setIsAnimating(true);
+ }, [sequential, computeOrder, fillAllIndices, shuffleText, text]);
useEffect(() => {
+ if (!isAnimating) return;
+
let interval;
let currentIteration = 0;
@@ -69,51 +176,11 @@ export default function DecryptedText({
}
};
- const availableChars = useOriginalCharsOnly
- ? Array.from(new Set(text.split(''))).filter(char => char !== ' ')
- : characters.split('');
-
- const shuffleText = (originalText, currentRevealed) => {
- if (useOriginalCharsOnly) {
- const positions = originalText.split('').map((char, i) => ({
- char,
- isSpace: char === ' ',
- index: i,
- isRevealed: currentRevealed.has(i)
- }));
-
- const nonSpaceChars = positions.filter(p => !p.isSpace && !p.isRevealed).map(p => p.char);
-
- for (let i = nonSpaceChars.length - 1; i > 0; i--) {
- const j = Math.floor(Math.random() * (i + 1));
- [nonSpaceChars[i], nonSpaceChars[j]] = [nonSpaceChars[j], nonSpaceChars[i]];
- }
-
- let charIndex = 0;
- return positions
- .map(p => {
- if (p.isSpace) return ' ';
- if (p.isRevealed) return originalText[p.index];
- return nonSpaceChars[charIndex++];
- })
- .join('');
- } else {
- return originalText
- .split('')
- .map((char, i) => {
- if (char === ' ') return ' ';
- if (currentRevealed.has(i)) return originalText[i];
- return availableChars[Math.floor(Math.random() * availableChars.length)];
- })
- .join('');
- }
- };
-
- if (isHovering) {
- setIsScrambling(true);
- interval = setInterval(() => {
- setRevealedIndices(prevRevealed => {
- if (sequential) {
+ interval = setInterval(() => {
+ setRevealedIndices(prevRevealed => {
+ if (sequential) {
+ // Forward
+ if (direction === 'forward') {
if (prevRevealed.size < text.length) {
const nextIndex = getNextIndex(prevRevealed);
const newRevealed = new Set(prevRevealed);
@@ -122,39 +189,135 @@ export default function DecryptedText({
return newRevealed;
} else {
clearInterval(interval);
- setIsScrambling(false);
+ setIsAnimating(false);
+ setIsDecrypted(true);
+ return prevRevealed;
+ }
+ }
+ // Reverse
+ if (direction === 'reverse') {
+ if (pointerRef.current < orderRef.current.length) {
+ const idxToRemove = orderRef.current[pointerRef.current++];
+ const newRevealed = new Set(prevRevealed);
+ newRevealed.delete(idxToRemove);
+ setDisplayText(shuffleText(text, newRevealed));
+ if (newRevealed.size === 0) {
+ clearInterval(interval);
+ setIsAnimating(false);
+ setIsDecrypted(false);
+ }
+ return newRevealed;
+ } else {
+ clearInterval(interval);
+ setIsAnimating(false);
+ setIsDecrypted(false);
return prevRevealed;
}
- } else {
+ }
+ } else {
+ // Non-Sequential
+ if (direction === 'forward') {
setDisplayText(shuffleText(text, prevRevealed));
currentIteration++;
if (currentIteration >= maxIterations) {
clearInterval(interval);
- setIsScrambling(false);
+ setIsAnimating(false);
setDisplayText(text);
+ setIsDecrypted(true);
}
return prevRevealed;
}
- });
- }, speed);
- } else {
- setDisplayText(text);
- setRevealedIndices(new Set());
- setIsScrambling(false);
+
+ // Non-Sequential Reverse
+ if (direction === 'reverse') {
+ let currentSet = prevRevealed;
+ if (currentSet.size === 0) {
+ currentSet = fillAllIndices();
+ }
+ const removeCount = Math.max(1, Math.ceil(text.length / Math.max(1, maxIterations)));
+ const nextSet = removeRandomIndices(currentSet, removeCount);
+ setDisplayText(shuffleText(text, nextSet));
+ currentIteration++;
+ if (nextSet.size === 0 || currentIteration >= maxIterations) {
+ clearInterval(interval);
+ setIsAnimating(false);
+ setIsDecrypted(false);
+ // ensure final scrambled state
+ setDisplayText(shuffleText(text, new Set()));
+ return new Set();
+ }
+ return nextSet;
+ }
+ }
+ return prevRevealed;
+ });
+ }, speed);
+
+ return () => clearInterval(interval);
+ }, [
+ isAnimating,
+ text,
+ speed,
+ maxIterations,
+ sequential,
+ revealDirection,
+ shuffleText,
+ direction,
+ fillAllIndices,
+ removeRandomIndices,
+ characters,
+ useOriginalCharsOnly
+ ]);
+
+ /* Click Behaviour */
+ const handleClick = () => {
+ if (animateOn !== 'click') return;
+
+ if (clickMode === 'once') {
+ if (isDecrypted) return;
+ setDirection('forward');
+ triggerDecrypt();
}
- return () => {
- if (interval) clearInterval(interval);
- };
- }, [isHovering, text, speed, maxIterations, sequential, revealDirection, characters, useOriginalCharsOnly]);
+ if (clickMode === 'toggle') {
+ if (isDecrypted) {
+ triggerReverse();
+ } else {
+ setDirection('forward');
+ triggerDecrypt();
+ }
+ }
+ };
+
+ /* Hover Behaviour */
+ const triggerHoverDecrypt = useCallback(() => {
+ if (isAnimating) return;
+ // Reset animation state cleanly
+ setRevealedIndices(new Set());
+ setIsDecrypted(false);
+ setDisplayText(text);
+
+ setDirection('forward');
+ setIsAnimating(true);
+ }, [isAnimating, text]);
+
+ const resetToPlainText = useCallback(() => {
+ setIsAnimating(false);
+ setRevealedIndices(new Set());
+ setDisplayText(text);
+ setIsDecrypted(true);
+ setDirection('forward');
+ }, [text]);
+
+ /* View Observer */
useEffect(() => {
- if (animateOn !== 'view' && animateOn !== 'both') return;
+ if (animateOn !== 'view' && animateOn !== 'inViewHover') return;
const observerCallback = entries => {
entries.forEach(entry => {
if (entry.isIntersecting && !hasAnimated) {
- setIsHovering(true);
+ triggerDecrypt();
setHasAnimated(true);
}
});
@@ -177,23 +340,38 @@ export default function DecryptedText({
observer.unobserve(currentRef);
}
};
- }, [animateOn, hasAnimated]);
+ }, [animateOn, hasAnimated, triggerDecrypt]);
- const hoverProps =
- animateOn === 'hover' || animateOn === 'both'
+ useEffect(() => {
+ if (animateOn === 'click') {
+ encryptInstantly();
+ } else {
+ setDisplayText(text);
+ setIsDecrypted(true);
+ }
+ setRevealedIndices(new Set());
+ setDirection('forward');
+ }, [animateOn, text, encryptInstantly]);
+
+ const animateProps =
+ animateOn === 'hover' || animateOn === 'inViewHover'
? {
- onMouseEnter: () => setIsHovering(true),
- onMouseLeave: () => setIsHovering(false)
+ onMouseEnter: triggerHoverDecrypt,
+ onMouseLeave: resetToPlainText
}
- : {};
+ : animateOn === 'click'
+ ? {
+ onClick: handleClick
+ }
+ : {};
return (
-
+
{displayText}
{displayText.split('').map((char, index) => {
- const isRevealedOrDone = revealedIndices.has(index) || !isScrambling || !isHovering;
+ const isRevealedOrDone = revealedIndices.has(index) || (!isAnimating && isDecrypted);
return (
diff --git a/src/demo/TextAnimations/DecryptedTextDemo.jsx b/src/demo/TextAnimations/DecryptedTextDemo.jsx
index f79ab602..ee9dff3b 100644
--- a/src/demo/TextAnimations/DecryptedTextDemo.jsx
+++ b/src/demo/TextAnimations/DecryptedTextDemo.jsx
@@ -23,12 +23,13 @@ const DEFAULT_PROPS = {
sequential: true,
useOriginalCharsOnly: false,
revealDirection: 'start',
- animateOn: 'view'
+ animateOn: 'view',
+ clickMode: 'once'
};
const DecryptedTextDemo = () => {
const { props, updateProp, resetProps, hasChanges } = useComponentProps(DEFAULT_PROPS);
- const { speed, maxIterations, sequential, useOriginalCharsOnly, revealDirection, animateOn } = props;
+ const { speed, maxIterations, sequential, useOriginalCharsOnly, revealDirection, animateOn, clickMode } = props;
const [key, forceRerender] = useForceRerender();
@@ -90,9 +91,15 @@ const DecryptedTextDemo = () => {
},
{
name: 'animateOn',
- type: `"view" | "hover"`,
+ type: `"view" | "hover" | "inViewHover" | "click"`,
default: `"hover"`,
description: 'Trigger scrambling on hover or scroll-into-view.'
+ },
+ {
+ name: 'clickMode',
+ type: `"once" | "toggle"`,
+ default: `"once"`,
+ description: 'Controls click behavior; only applies when animateOn is "click".'
}
],
[]
@@ -101,7 +108,12 @@ const DecryptedTextDemo = () => {
const animateOptions = [
{ label: 'View', value: 'view' },
{ label: 'Hover', value: 'hover' },
- { label: 'Both', value: 'both' }
+ { label: 'View & Hover', value: 'inViewHover' },
+ { label: 'Click', value: 'click' }
+ ];
+ const clickOptions = [
+ { label: 'Once', value: 'once' },
+ { label: 'Toggle', value: 'toggle' }
];
const directionOptions = [
{ label: 'Start', value: 'start' },
@@ -127,6 +139,7 @@ const DecryptedTextDemo = () => {
parentClassName="decrypted-text"
useOriginalCharsOnly={useOriginalCharsOnly}
animateOn={animateOn}
+ clickMode={clickMode}
/>
@@ -137,13 +150,26 @@ const DecryptedTextDemo = () => {
options={animateOptions}
value={animateOn}
name="animateOn"
- width={100}
+ width={150}
onChange={val => {
updateProp('animateOn', val);
forceRerender();
}}
/>
+ {
+ updateProp('clickMode', val);
+ forceRerender();
+ }}
+ />
+
{
+ return useOriginalCharsOnly
+ ? Array.from(new Set(text.split(''))).filter(char => char !== ' ')
+ : characters.split('');
+ }, [useOriginalCharsOnly, text, characters]);
+
+ const shuffleText = useCallback(
+ (originalText, currentRevealed) => {
+ return originalText
+ .split('')
+ .map((char, i) => {
+ if (char === ' ') return ' ';
+ if (currentRevealed.has(i)) return originalText[i];
+ return availableChars[Math.floor(Math.random() * availableChars.length)];
+ })
+ .join('');
+ },
+ [availableChars]
+ );
+
+ const computeOrder = useCallback(
+ len => {
+ const order = [];
+ if (len <= 0) return order;
+ if (revealDirection === 'start') {
+ for (let i = 0; i < len; i++) order.push(i);
+ return order;
+ }
+ if (revealDirection === 'end') {
+ for (let i = len - 1; i >= 0; i--) order.push(i);
+ return order;
+ }
+ // center
+ const middle = Math.floor(len / 2);
+ let offset = 0;
+ while (order.length < len) {
+ if (offset % 2 === 0) {
+ const idx = middle + offset / 2;
+ if (idx >= 0 && idx < len) order.push(idx);
+ } else {
+ const idx = middle - Math.ceil(offset / 2);
+ if (idx >= 0 && idx < len) order.push(idx);
+ }
+ offset++;
+ }
+ return order.slice(0, len);
+ },
+ [revealDirection]
+ );
+
+ const fillAllIndices = useCallback(() => {
+ const s = new Set();
+ for (let i = 0; i < text.length; i++) s.add(i);
+ return s;
+ }, [text]);
+
+ const removeRandomIndices = useCallback((set, count) => {
+ const arr = Array.from(set);
+ for (let i = 0; i < count && arr.length > 0; i++) {
+ const idx = Math.floor(Math.random() * arr.length);
+ arr.splice(idx, 1);
+ }
+ return new Set(arr);
+ }, []);
+
+ const encryptInstantly = useCallback(() => {
+ const emptySet = new Set();
+ setRevealedIndices(emptySet);
+ setDisplayText(shuffleText(text, emptySet));
+ setIsDecrypted(false);
+ }, [text, shuffleText]);
+
+ const triggerDecrypt = useCallback(() => {
+ if (sequential) {
+ orderRef.current = computeOrder(text.length);
+ pointerRef.current = 0;
+ setRevealedIndices(new Set());
+ } else {
+ setRevealedIndices(new Set());
+ }
+ setDirection('forward');
+ setIsAnimating(true);
+ }, [sequential, computeOrder, text.length]);
+
+ const triggerReverse = useCallback(() => {
+ if (sequential) {
+ // compute forward order then reverse it: we'll remove indices in that order
+ orderRef.current = computeOrder(text.length).slice().reverse();
+ pointerRef.current = 0;
+ setRevealedIndices(fillAllIndices()); // start fully revealed
+ setDisplayText(shuffleText(text, fillAllIndices()));
+ } else {
+ // non-seq: start from fully revealed as well
+ setRevealedIndices(fillAllIndices());
+ setDisplayText(shuffleText(text, fillAllIndices()));
+ }
+ setDirection('reverse');
+ setIsAnimating(true);
+ }, [sequential, computeOrder, fillAllIndices, shuffleText, text]);
useEffect(() => {
+ if (!isAnimating) return;
+
let interval;
let currentIteration = 0;
@@ -51,51 +158,11 @@ export default function DecryptedText({
}
};
- const availableChars = useOriginalCharsOnly
- ? Array.from(new Set(text.split(''))).filter(char => char !== ' ')
- : characters.split('');
-
- const shuffleText = (originalText, currentRevealed) => {
- if (useOriginalCharsOnly) {
- const positions = originalText.split('').map((char, i) => ({
- char,
- isSpace: char === ' ',
- index: i,
- isRevealed: currentRevealed.has(i)
- }));
-
- const nonSpaceChars = positions.filter(p => !p.isSpace && !p.isRevealed).map(p => p.char);
-
- for (let i = nonSpaceChars.length - 1; i > 0; i--) {
- const j = Math.floor(Math.random() * (i + 1));
- [nonSpaceChars[i], nonSpaceChars[j]] = [nonSpaceChars[j], nonSpaceChars[i]];
- }
-
- let charIndex = 0;
- return positions
- .map(p => {
- if (p.isSpace) return ' ';
- if (p.isRevealed) return originalText[p.index];
- return nonSpaceChars[charIndex++];
- })
- .join('');
- } else {
- return originalText
- .split('')
- .map((char, i) => {
- if (char === ' ') return ' ';
- if (currentRevealed.has(i)) return originalText[i];
- return availableChars[Math.floor(Math.random() * availableChars.length)];
- })
- .join('');
- }
- };
-
- if (isHovering) {
- setIsScrambling(true);
- interval = setInterval(() => {
- setRevealedIndices(prevRevealed => {
- if (sequential) {
+ interval = setInterval(() => {
+ setRevealedIndices(prevRevealed => {
+ if (sequential) {
+ // Forward
+ if (direction === 'forward') {
if (prevRevealed.size < text.length) {
const nextIndex = getNextIndex(prevRevealed);
const newRevealed = new Set(prevRevealed);
@@ -104,39 +171,135 @@ export default function DecryptedText({
return newRevealed;
} else {
clearInterval(interval);
- setIsScrambling(false);
+ setIsAnimating(false);
+ setIsDecrypted(true);
return prevRevealed;
}
- } else {
+ }
+
+ // Reverse
+ if (direction === 'reverse') {
+ if (pointerRef.current < orderRef.current.length) {
+ const idxToRemove = orderRef.current[pointerRef.current++];
+ const newRevealed = new Set(prevRevealed);
+ newRevealed.delete(idxToRemove);
+ setDisplayText(shuffleText(text, newRevealed));
+ if (newRevealed.size === 0) {
+ clearInterval(interval);
+ setIsAnimating(false);
+ setIsDecrypted(false);
+ }
+ return newRevealed;
+ } else {
+ clearInterval(interval);
+ setIsAnimating(false);
+ setIsDecrypted(false);
+ return prevRevealed;
+ }
+ }
+ } else {
+ // Non-Sequential
+ if (direction === 'forward') {
setDisplayText(shuffleText(text, prevRevealed));
currentIteration++;
if (currentIteration >= maxIterations) {
clearInterval(interval);
- setIsScrambling(false);
+ setIsAnimating(false);
setDisplayText(text);
+ setIsDecrypted(true);
}
return prevRevealed;
}
- });
- }, speed);
- } else {
- setDisplayText(text);
- setRevealedIndices(new Set());
- setIsScrambling(false);
+
+ // Non-Sequential Reverse
+ if (direction === 'reverse') {
+ let currentSet = prevRevealed;
+ if (currentSet.size === 0) {
+ currentSet = fillAllIndices();
+ }
+ const removeCount = Math.max(1, Math.ceil(text.length / Math.max(1, maxIterations)));
+ const nextSet = removeRandomIndices(currentSet, removeCount);
+ setDisplayText(shuffleText(text, nextSet));
+ currentIteration++;
+ if (nextSet.size === 0 || currentIteration >= maxIterations) {
+ clearInterval(interval);
+ setIsAnimating(false);
+ setIsDecrypted(false);
+ // ensure final scrambled state
+ setDisplayText(shuffleText(text, new Set()));
+ return new Set();
+ }
+ return nextSet;
+ }
+ }
+ return prevRevealed;
+ });
+ }, speed);
+
+ return () => clearInterval(interval);
+ }, [
+ isAnimating,
+ text,
+ speed,
+ maxIterations,
+ sequential,
+ revealDirection,
+ shuffleText,
+ direction,
+ fillAllIndices,
+ removeRandomIndices,
+ characters,
+ useOriginalCharsOnly
+ ]);
+
+ /* Click Behaviour */
+ const handleClick = () => {
+ if (animateOn !== 'click') return;
+
+ if (clickMode === 'once') {
+ if (isDecrypted) return;
+ setDirection('forward');
+ triggerDecrypt();
}
- return () => {
- if (interval) clearInterval(interval);
- };
- }, [isHovering, text, speed, maxIterations, sequential, revealDirection, characters, useOriginalCharsOnly]);
+ if (clickMode === 'toggle') {
+ if (isDecrypted) {
+ triggerReverse();
+ } else {
+ setDirection('forward');
+ triggerDecrypt();
+ }
+ }
+ };
+
+ /* Hover Behaviour */
+ const triggerHoverDecrypt = useCallback(() => {
+ if (isAnimating) return;
+
+ // Reset animation state cleanly
+ setRevealedIndices(new Set());
+ setIsDecrypted(false);
+ setDisplayText(text);
+
+ setDirection('forward');
+ setIsAnimating(true);
+ }, [isAnimating, text]);
+
+ const resetToPlainText = useCallback(() => {
+ setIsAnimating(false);
+ setRevealedIndices(new Set());
+ setDisplayText(text);
+ setIsDecrypted(true);
+ setDirection('forward');
+ }, [text]);
useEffect(() => {
- if (animateOn !== 'view' && animateOn !== 'both') return;
+ if (animateOn !== 'view' && animateOn !== 'inViewHover') return;
const observerCallback = entries => {
entries.forEach(entry => {
if (entry.isIntersecting && !hasAnimated) {
- setIsHovering(true);
+ triggerDecrypt();
setHasAnimated(true);
}
});
@@ -157,28 +320,43 @@ export default function DecryptedText({
return () => {
if (currentRef) observer.unobserve(currentRef);
};
- }, [animateOn, hasAnimated]);
+ }, [animateOn, hasAnimated, triggerDecrypt]);
- const hoverProps =
- animateOn === 'hover' || animateOn === 'both'
+ useEffect(() => {
+ if (animateOn === 'click') {
+ encryptInstantly();
+ } else {
+ setDisplayText(text);
+ setIsDecrypted(true);
+ }
+ setRevealedIndices(new Set());
+ setDirection('forward');
+ }, [animateOn, text, encryptInstantly]);
+
+ const animateProps =
+ animateOn === 'hover' || animateOn === 'inViewHover'
? {
- onMouseEnter: () => setIsHovering(true),
- onMouseLeave: () => setIsHovering(false)
+ onMouseEnter: triggerHoverDecrypt,
+ onMouseLeave: resetToPlainText
}
- : {};
+ : animateOn === 'click'
+ ? {
+ onClick: handleClick
+ }
+ : {};
return (
{displayText}
{displayText.split('').map((char, index) => {
- const isRevealedOrDone = revealedIndices.has(index) || !isScrambling || !isHovering;
+ const isRevealedOrDone = revealedIndices.has(index) || (!isAnimating && isDecrypted);
return (
diff --git a/src/ts-default/TextAnimations/DecryptedText/DecryptedText.tsx b/src/ts-default/TextAnimations/DecryptedText/DecryptedText.tsx
index a49f017c..a46b3c04 100644
--- a/src/ts-default/TextAnimations/DecryptedText/DecryptedText.tsx
+++ b/src/ts-default/TextAnimations/DecryptedText/DecryptedText.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState, useRef } from 'react';
+import { useEffect, useState, useRef, useMemo, useCallback } from 'react';
import { motion } from 'motion/react';
import type { HTMLMotionProps } from 'motion/react';
@@ -30,9 +30,12 @@ interface DecryptedTextProps extends HTMLMotionProps<'span'> {
className?: string;
parentClassName?: string;
encryptedClassName?: string;
- animateOn?: 'view' | 'hover' | 'both';
+ animateOn?: 'view' | 'hover' | 'inViewHover' | 'click';
+ clickMode?: 'once' | 'toggle';
}
+type Direction = 'forward' | 'reverse';
+
export default function DecryptedText({
text,
speed = 50,
@@ -45,16 +48,123 @@ export default function DecryptedText({
parentClassName = '',
encryptedClassName = '',
animateOn = 'hover',
+ clickMode = 'once',
...props
}: DecryptedTextProps) {
const [displayText, setDisplayText] = useState(text);
- const [isHovering, setIsHovering] = useState(false);
- const [isScrambling, setIsScrambling] = useState(false);
+ const [isAnimating, setIsAnimating] = useState(false);
const [revealedIndices, setRevealedIndices] = useState>(new Set());
const [hasAnimated, setHasAnimated] = useState(false);
+ const [isDecrypted, setIsDecrypted] = useState(animateOn !== 'click');
+ const [direction, setDirection] = useState('forward');
+
const containerRef = useRef(null);
+ const orderRef = useRef([]);
+ const pointerRef = useRef(0);
+
+ const availableChars = useMemo(() => {
+ return useOriginalCharsOnly
+ ? Array.from(new Set(text.split(''))).filter(char => char !== ' ')
+ : characters.split('');
+ }, [useOriginalCharsOnly, text, characters]);
+
+ const shuffleText = useCallback(
+ (originalText: string, currentRevealed: Set) => {
+ return originalText
+ .split('')
+ .map((char, i) => {
+ if (char === ' ') return ' ';
+ if (currentRevealed.has(i)) return originalText[i];
+ return availableChars[Math.floor(Math.random() * availableChars.length)];
+ })
+ .join('');
+ },
+ [availableChars]
+ );
+
+ const computeOrder = useCallback(
+ (len: number): number[] => {
+ const order: number[] = [];
+ if (len <= 0) return order;
+ if (revealDirection === 'start') {
+ for (let i = 0; i < len; i++) order.push(i);
+ return order;
+ }
+ if (revealDirection === 'end') {
+ for (let i = len - 1; i >= 0; i--) order.push(i);
+ return order;
+ }
+ // center
+ const middle = Math.floor(len / 2);
+ let offset = 0;
+ while (order.length < len) {
+ if (offset % 2 === 0) {
+ const idx = middle + offset / 2;
+ if (idx >= 0 && idx < len) order.push(idx);
+ } else {
+ const idx = middle - Math.ceil(offset / 2);
+ if (idx >= 0 && idx < len) order.push(idx);
+ }
+ offset++;
+ }
+ return order.slice(0, len);
+ },
+ [revealDirection]
+ );
+
+ const fillAllIndices = useCallback((): Set => {
+ const s = new Set();
+ for (let i = 0; i < text.length; i++) s.add(i);
+ return s;
+ }, [text]);
+
+ const removeRandomIndices = useCallback((set: Set, count: number): Set => {
+ const arr = Array.from(set);
+ for (let i = 0; i < count && arr.length > 0; i++) {
+ const idx = Math.floor(Math.random() * arr.length);
+ arr.splice(idx, 1);
+ }
+ return new Set(arr);
+ }, []);
+
+ const encryptInstantly = useCallback(() => {
+ const emptySet = new Set();
+ setRevealedIndices(emptySet);
+ setDisplayText(shuffleText(text, emptySet));
+ setIsDecrypted(false);
+ }, [text, shuffleText]);
+
+ const triggerDecrypt = useCallback(() => {
+ if (sequential) {
+ orderRef.current = computeOrder(text.length);
+ pointerRef.current = 0;
+ setRevealedIndices(new Set());
+ } else {
+ setRevealedIndices(new Set());
+ }
+ setDirection('forward');
+ setIsAnimating(true);
+ }, [sequential, computeOrder, text.length]);
+
+ const triggerReverse = useCallback(() => {
+ if (sequential) {
+ // compute forward order then reverse it: we'll remove indices in that order
+ orderRef.current = computeOrder(text.length).slice().reverse();
+ pointerRef.current = 0;
+ setRevealedIndices(fillAllIndices()); // start fully revealed
+ setDisplayText(shuffleText(text, fillAllIndices()));
+ } else {
+ // non-seq: start from fully revealed as well
+ setRevealedIndices(fillAllIndices());
+ setDisplayText(shuffleText(text, fillAllIndices()));
+ }
+ setDirection('reverse');
+ setIsAnimating(true);
+ }, [sequential, computeOrder, fillAllIndices, shuffleText, text]);
useEffect(() => {
+ if (!isAnimating) return;
+
let interval: ReturnType;
let currentIteration = 0;
@@ -84,51 +194,11 @@ export default function DecryptedText({
}
};
- const availableChars = useOriginalCharsOnly
- ? Array.from(new Set(text.split(''))).filter(char => char !== ' ')
- : characters.split('');
-
- const shuffleText = (originalText: string, currentRevealed: Set): string => {
- if (useOriginalCharsOnly) {
- const positions = originalText.split('').map((char, i) => ({
- char,
- isSpace: char === ' ',
- index: i,
- isRevealed: currentRevealed.has(i)
- }));
-
- const nonSpaceChars = positions.filter(p => !p.isSpace && !p.isRevealed).map(p => p.char);
-
- for (let i = nonSpaceChars.length - 1; i > 0; i--) {
- const j = Math.floor(Math.random() * (i + 1));
- [nonSpaceChars[i], nonSpaceChars[j]] = [nonSpaceChars[j], nonSpaceChars[i]];
- }
-
- let charIndex = 0;
- return positions
- .map(p => {
- if (p.isSpace) return ' ';
- if (p.isRevealed) return originalText[p.index];
- return nonSpaceChars[charIndex++];
- })
- .join('');
- } else {
- return originalText
- .split('')
- .map((char, i) => {
- if (char === ' ') return ' ';
- if (currentRevealed.has(i)) return originalText[i];
- return availableChars[Math.floor(Math.random() * availableChars.length)];
- })
- .join('');
- }
- };
-
- if (isHovering) {
- setIsScrambling(true);
- interval = setInterval(() => {
- setRevealedIndices(prevRevealed => {
- if (sequential) {
+ interval = setInterval(() => {
+ setRevealedIndices(prevRevealed => {
+ if (sequential) {
+ // Forward
+ if (direction === 'forward') {
if (prevRevealed.size < text.length) {
const nextIndex = getNextIndex(prevRevealed);
const newRevealed = new Set(prevRevealed);
@@ -137,39 +207,135 @@ export default function DecryptedText({
return newRevealed;
} else {
clearInterval(interval);
- setIsScrambling(false);
+ setIsAnimating(false);
+ setIsDecrypted(true);
return prevRevealed;
}
- } else {
+ }
+ // Reverse
+ if (direction === 'reverse') {
+ if (pointerRef.current < orderRef.current.length) {
+ const idxToRemove = orderRef.current[pointerRef.current++];
+ const newRevealed = new Set(prevRevealed);
+ newRevealed.delete(idxToRemove);
+ setDisplayText(shuffleText(text, newRevealed));
+ if (newRevealed.size === 0) {
+ clearInterval(interval);
+ setIsAnimating(false);
+ setIsDecrypted(false);
+ }
+ return newRevealed;
+ } else {
+ clearInterval(interval);
+ setIsAnimating(false);
+ setIsDecrypted(false);
+ return prevRevealed;
+ }
+ }
+ } else {
+ // Non-Sequential
+ if (direction === 'forward') {
setDisplayText(shuffleText(text, prevRevealed));
currentIteration++;
if (currentIteration >= maxIterations) {
clearInterval(interval);
- setIsScrambling(false);
+ setIsAnimating(false);
setDisplayText(text);
+ setIsDecrypted(true);
}
return prevRevealed;
}
- });
- }, speed);
- } else {
- setDisplayText(text);
- setRevealedIndices(new Set());
- setIsScrambling(false);
+
+ // Non-Sequential Reverse
+ if (direction === 'reverse') {
+ let currentSet = prevRevealed;
+ if (currentSet.size === 0) {
+ currentSet = fillAllIndices();
+ }
+ const removeCount = Math.max(1, Math.ceil(text.length / Math.max(1, maxIterations)));
+ const nextSet = removeRandomIndices(currentSet, removeCount);
+ setDisplayText(shuffleText(text, nextSet));
+ currentIteration++;
+ if (nextSet.size === 0 || currentIteration >= maxIterations) {
+ clearInterval(interval);
+ setIsAnimating(false);
+ setIsDecrypted(false);
+ // ensure final scrambled state
+ setDisplayText(shuffleText(text, new Set()));
+ return new Set();
+ }
+ return nextSet;
+ }
+ }
+ return prevRevealed;
+ });
+ }, speed);
+
+ return () => clearInterval(interval);
+ }, [
+ isAnimating,
+ text,
+ speed,
+ maxIterations,
+ sequential,
+ revealDirection,
+ shuffleText,
+ direction,
+ fillAllIndices,
+ removeRandomIndices,
+ characters,
+ useOriginalCharsOnly
+ ]);
+
+ /* Click Behaviour */
+ const handleClick = () => {
+ if (animateOn !== 'click') return;
+
+ if (clickMode === 'once') {
+ if (isDecrypted) return;
+ setDirection('forward');
+ triggerDecrypt();
}
- return () => {
- if (interval) clearInterval(interval);
- };
- }, [isHovering, text, speed, maxIterations, sequential, revealDirection, characters, useOriginalCharsOnly]);
+ if (clickMode === 'toggle') {
+ if (isDecrypted) {
+ triggerReverse();
+ } else {
+ setDirection('forward');
+ triggerDecrypt();
+ }
+ }
+ };
+
+ /* Hover Behaviour */
+ const triggerHoverDecrypt = useCallback(() => {
+ if (isAnimating) return;
+ // Reset animation state cleanly
+ setRevealedIndices(new Set());
+ setIsDecrypted(false);
+ setDisplayText(text);
+
+ setDirection('forward');
+ setIsAnimating(true);
+ }, [isAnimating, text]);
+
+ const resetToPlainText = useCallback(() => {
+ setIsAnimating(false);
+ setRevealedIndices(new Set());
+ setDisplayText(text);
+ setIsDecrypted(true);
+ setDirection('forward');
+ }, [text]);
+
+ /* View Observer */
useEffect(() => {
- if (animateOn !== 'view' && animateOn !== 'both') return;
+ if (animateOn !== 'view' && animateOn !== 'inViewHover') return;
const observerCallback = (entries: IntersectionObserverEntry[]) => {
entries.forEach(entry => {
if (entry.isIntersecting && !hasAnimated) {
- setIsHovering(true);
+ triggerDecrypt();
setHasAnimated(true);
}
});
@@ -192,23 +358,38 @@ export default function DecryptedText({
observer.unobserve(currentRef);
}
};
- }, [animateOn, hasAnimated]);
+ }, [animateOn, hasAnimated, triggerDecrypt]);
- const hoverProps =
- animateOn === 'hover' || animateOn === 'both'
+ useEffect(() => {
+ if (animateOn === 'click') {
+ encryptInstantly();
+ } else {
+ setDisplayText(text);
+ setIsDecrypted(true);
+ }
+ setRevealedIndices(new Set());
+ setDirection('forward');
+ }, [animateOn, text, encryptInstantly]);
+
+ const animateProps =
+ animateOn === 'hover' || animateOn === 'inViewHover'
? {
- onMouseEnter: () => setIsHovering(true),
- onMouseLeave: () => setIsHovering(false)
+ onMouseEnter: triggerHoverDecrypt,
+ onMouseLeave: resetToPlainText
}
- : {};
+ : animateOn === 'click'
+ ? {
+ onClick: handleClick
+ }
+ : {};
return (
-
+
{displayText}
{displayText.split('').map((char, index) => {
- const isRevealedOrDone = revealedIndices.has(index) || !isScrambling || !isHovering;
+ const isRevealedOrDone = revealedIndices.has(index) || (!isAnimating && isDecrypted);
return (
diff --git a/src/ts-tailwind/TextAnimations/DecryptedText/DecryptedText.tsx b/src/ts-tailwind/TextAnimations/DecryptedText/DecryptedText.tsx
index 350a9cd1..09dac7d0 100644
--- a/src/ts-tailwind/TextAnimations/DecryptedText/DecryptedText.tsx
+++ b/src/ts-tailwind/TextAnimations/DecryptedText/DecryptedText.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState, useRef } from 'react';
+import { useEffect, useState, useRef, useMemo, useCallback } from 'react';
import { motion } from 'motion/react';
import type { HTMLMotionProps } from 'motion/react';
@@ -13,9 +13,12 @@ interface DecryptedTextProps extends HTMLMotionProps<'span'> {
className?: string;
encryptedClassName?: string;
parentClassName?: string;
- animateOn?: 'view' | 'hover' | 'both';
+ animateOn?: 'view' | 'hover' | 'inViewHover' | 'click';
+ clickMode?: 'once' | 'toggle';
}
+type Direction = 'forward' | 'reverse';
+
export default function DecryptedText({
text,
speed = 50,
@@ -28,16 +31,123 @@ export default function DecryptedText({
parentClassName = '',
encryptedClassName = '',
animateOn = 'hover',
+ clickMode = 'once',
...props
}: DecryptedTextProps) {
const [displayText, setDisplayText] = useState(text);
- const [isHovering, setIsHovering] = useState(false);
- const [isScrambling, setIsScrambling] = useState(false);
+ const [isAnimating, setIsAnimating] = useState(false);
const [revealedIndices, setRevealedIndices] = useState>(new Set());
const [hasAnimated, setHasAnimated] = useState(false);
+ const [isDecrypted, setIsDecrypted] = useState(animateOn !== 'click');
+ const [direction, setDirection] = useState('forward');
+
const containerRef = useRef(null);
+ const orderRef = useRef([]);
+ const pointerRef = useRef(0);
+
+ const availableChars = useMemo(() => {
+ return useOriginalCharsOnly
+ ? Array.from(new Set(text.split(''))).filter(char => char !== ' ')
+ : characters.split('');
+ }, [useOriginalCharsOnly, text, characters]);
+
+ const shuffleText = useCallback(
+ (originalText: string, currentRevealed: Set) => {
+ return originalText
+ .split('')
+ .map((char, i) => {
+ if (char === ' ') return ' ';
+ if (currentRevealed.has(i)) return originalText[i];
+ return availableChars[Math.floor(Math.random() * availableChars.length)];
+ })
+ .join('');
+ },
+ [availableChars]
+ );
+
+ const computeOrder = useCallback(
+ (len: number): number[] => {
+ const order: number[] = [];
+ if (len <= 0) return order;
+ if (revealDirection === 'start') {
+ for (let i = 0; i < len; i++) order.push(i);
+ return order;
+ }
+ if (revealDirection === 'end') {
+ for (let i = len - 1; i >= 0; i--) order.push(i);
+ return order;
+ }
+ // center
+ const middle = Math.floor(len / 2);
+ let offset = 0;
+ while (order.length < len) {
+ if (offset % 2 === 0) {
+ const idx = middle + offset / 2;
+ if (idx >= 0 && idx < len) order.push(idx);
+ } else {
+ const idx = middle - Math.ceil(offset / 2);
+ if (idx >= 0 && idx < len) order.push(idx);
+ }
+ offset++;
+ }
+ return order.slice(0, len);
+ },
+ [revealDirection]
+ );
+
+ const fillAllIndices = useCallback((): Set => {
+ const s = new Set();
+ for (let i = 0; i < text.length; i++) s.add(i);
+ return s;
+ }, [text]);
+
+ const removeRandomIndices = useCallback((set: Set, count: number): Set => {
+ const arr = Array.from(set);
+ for (let i = 0; i < count && arr.length > 0; i++) {
+ const idx = Math.floor(Math.random() * arr.length);
+ arr.splice(idx, 1);
+ }
+ return new Set(arr);
+ }, []);
+
+ const encryptInstantly = useCallback(() => {
+ const emptySet = new Set();
+ setRevealedIndices(emptySet);
+ setDisplayText(shuffleText(text, emptySet));
+ setIsDecrypted(false);
+ }, [text, shuffleText]);
+
+ const triggerDecrypt = useCallback(() => {
+ if (sequential) {
+ orderRef.current = computeOrder(text.length);
+ pointerRef.current = 0;
+ setRevealedIndices(new Set());
+ } else {
+ setRevealedIndices(new Set());
+ }
+ setDirection('forward');
+ setIsAnimating(true);
+ }, [sequential, computeOrder, text.length]);
+
+ const triggerReverse = useCallback(() => {
+ if (sequential) {
+ // compute forward order then reverse it: we'll remove indices in that order
+ orderRef.current = computeOrder(text.length).slice().reverse();
+ pointerRef.current = 0;
+ setRevealedIndices(fillAllIndices()); // start fully revealed
+ setDisplayText(shuffleText(text, fillAllIndices()));
+ } else {
+ // non-seq: start from fully revealed as well
+ setRevealedIndices(fillAllIndices());
+ setDisplayText(shuffleText(text, fillAllIndices()));
+ }
+ setDirection('reverse');
+ setIsAnimating(true);
+ }, [sequential, computeOrder, fillAllIndices, shuffleText, text]);
useEffect(() => {
+ if (!isAnimating) return;
+
let interval: ReturnType;
let currentIteration = 0;
@@ -66,51 +176,11 @@ export default function DecryptedText({
}
};
- const availableChars = useOriginalCharsOnly
- ? Array.from(new Set(text.split(''))).filter(char => char !== ' ')
- : characters.split('');
-
- const shuffleText = (originalText: string, currentRevealed: Set): string => {
- if (useOriginalCharsOnly) {
- const positions = originalText.split('').map((char, i) => ({
- char,
- isSpace: char === ' ',
- index: i,
- isRevealed: currentRevealed.has(i)
- }));
-
- const nonSpaceChars = positions.filter(p => !p.isSpace && !p.isRevealed).map(p => p.char);
-
- for (let i = nonSpaceChars.length - 1; i > 0; i--) {
- const j = Math.floor(Math.random() * (i + 1));
- [nonSpaceChars[i], nonSpaceChars[j]] = [nonSpaceChars[j], nonSpaceChars[i]];
- }
-
- let charIndex = 0;
- return positions
- .map(p => {
- if (p.isSpace) return ' ';
- if (p.isRevealed) return originalText[p.index];
- return nonSpaceChars[charIndex++];
- })
- .join('');
- } else {
- return originalText
- .split('')
- .map((char, i) => {
- if (char === ' ') return ' ';
- if (currentRevealed.has(i)) return originalText[i];
- return availableChars[Math.floor(Math.random() * availableChars.length)];
- })
- .join('');
- }
- };
-
- if (isHovering) {
- setIsScrambling(true);
- interval = setInterval(() => {
- setRevealedIndices(prevRevealed => {
- if (sequential) {
+ interval = setInterval(() => {
+ setRevealedIndices(prevRevealed => {
+ if (sequential) {
+ // Forward
+ if (direction === 'forward') {
if (prevRevealed.size < text.length) {
const nextIndex = getNextIndex(prevRevealed);
const newRevealed = new Set(prevRevealed);
@@ -119,39 +189,134 @@ export default function DecryptedText({
return newRevealed;
} else {
clearInterval(interval);
- setIsScrambling(false);
+ setIsAnimating(false);
+ setIsDecrypted(true);
return prevRevealed;
}
- } else {
+ }
+ // Reverse
+ if (direction === 'reverse') {
+ if (pointerRef.current < orderRef.current.length) {
+ const idxToRemove = orderRef.current[pointerRef.current++];
+ const newRevealed = new Set(prevRevealed);
+ newRevealed.delete(idxToRemove);
+ setDisplayText(shuffleText(text, newRevealed));
+ if (newRevealed.size === 0) {
+ clearInterval(interval);
+ setIsAnimating(false);
+ setIsDecrypted(false);
+ }
+ return newRevealed;
+ } else {
+ clearInterval(interval);
+ setIsAnimating(false);
+ setIsDecrypted(false);
+ return prevRevealed;
+ }
+ }
+ } else {
+ // Non-Sequential
+ if (direction === 'forward') {
setDisplayText(shuffleText(text, prevRevealed));
currentIteration++;
if (currentIteration >= maxIterations) {
clearInterval(interval);
- setIsScrambling(false);
+ setIsAnimating(false);
setDisplayText(text);
+ setIsDecrypted(true);
}
return prevRevealed;
}
- });
- }, speed);
- } else {
- setDisplayText(text);
- setRevealedIndices(new Set());
- setIsScrambling(false);
+
+ // Non-Sequential Reverse
+ if (direction === 'reverse') {
+ let currentSet = prevRevealed;
+ if (currentSet.size === 0) {
+ currentSet = fillAllIndices();
+ }
+ const removeCount = Math.max(1, Math.ceil(text.length / Math.max(1, maxIterations)));
+ const nextSet = removeRandomIndices(currentSet, removeCount);
+ setDisplayText(shuffleText(text, nextSet));
+ currentIteration++;
+ if (nextSet.size === 0 || currentIteration >= maxIterations) {
+ clearInterval(interval);
+ setIsAnimating(false);
+ setIsDecrypted(false);
+ // ensure final scrambled state
+ setDisplayText(shuffleText(text, new Set()));
+ return new Set();
+ }
+ return nextSet;
+ }
+ }
+ return prevRevealed;
+ });
+ }, speed);
+ return () => clearInterval(interval);
+ }, [
+ isAnimating,
+ text,
+ speed,
+ maxIterations,
+ sequential,
+ revealDirection,
+ shuffleText,
+ direction,
+ fillAllIndices,
+ removeRandomIndices,
+ characters,
+ useOriginalCharsOnly
+ ]);
+
+ /* Click Behaviour */
+ const handleClick = () => {
+ if (animateOn !== 'click') return;
+
+ if (clickMode === 'once') {
+ if (isDecrypted) return;
+ setDirection('forward');
+ triggerDecrypt();
}
- return () => {
- if (interval) clearInterval(interval);
- };
- }, [isHovering, text, speed, maxIterations, sequential, revealDirection, characters, useOriginalCharsOnly]);
+ if (clickMode === 'toggle') {
+ if (isDecrypted) {
+ triggerReverse();
+ } else {
+ setDirection('forward');
+ triggerDecrypt();
+ }
+ }
+ };
+
+ /* Hover Behaviour */
+ const triggerHoverDecrypt = useCallback(() => {
+ if (isAnimating) return;
+ // Reset animation state cleanly
+ setRevealedIndices(new Set());
+ setIsDecrypted(false);
+ setDisplayText(text);
+
+ setDirection('forward');
+ setIsAnimating(true);
+ }, [isAnimating, text]);
+
+ const resetToPlainText = useCallback(() => {
+ setIsAnimating(false);
+ setRevealedIndices(new Set());
+ setDisplayText(text);
+ setIsDecrypted(true);
+ setDirection('forward');
+ }, [text]);
+
+ /* View Observer */
useEffect(() => {
- if (animateOn !== 'view' && animateOn !== 'both') return;
+ if (animateOn !== 'view' && animateOn !== 'inViewHover') return;
const observerCallback = (entries: IntersectionObserverEntry[]) => {
entries.forEach(entry => {
if (entry.isIntersecting && !hasAnimated) {
- setIsHovering(true);
+ triggerDecrypt();
setHasAnimated(true);
}
});
@@ -172,28 +337,43 @@ export default function DecryptedText({
return () => {
if (currentRef) observer.unobserve(currentRef);
};
- }, [animateOn, hasAnimated]);
+ }, [animateOn, hasAnimated, triggerDecrypt]);
- const hoverProps =
- animateOn === 'hover' || animateOn === 'both'
+ useEffect(() => {
+ if (animateOn === 'click') {
+ encryptInstantly();
+ } else {
+ setDisplayText(text);
+ setIsDecrypted(true);
+ }
+ setRevealedIndices(new Set());
+ setDirection('forward');
+ }, [animateOn, text, encryptInstantly]);
+
+ const animateProps =
+ animateOn === 'hover' || animateOn === 'inViewHover'
? {
- onMouseEnter: () => setIsHovering(true),
- onMouseLeave: () => setIsHovering(false)
+ onMouseEnter: triggerHoverDecrypt,
+ onMouseLeave: resetToPlainText
}
- : {};
+ : animateOn === 'click'
+ ? {
+ onClick: handleClick
+ }
+ : {};
return (
{displayText}
{displayText.split('').map((char, index) => {
- const isRevealedOrDone = revealedIndices.has(index) || !isScrambling || !isHovering;
+ const isRevealedOrDone = revealedIndices.has(index) || (!isAnimating && isDecrypted);
return (