[uic:decrypted-text]
Эффект дешифровки: символы заменяются случайными из набора, затем поочерёдно или хаотично раскрываются. Поддерживает четыре триггера, последовательный режим с тремя направлениями и режим toggle при клике.
Зависимости
| Пакет |
Версия |
Назначение |
framer-motion |
^11 |
motion.span для контейнера |
react |
^18 или ^19 |
Базовый фреймворк |
npm install framer-motion
Использование
import DecryptedText from '@/features/ui-components/decrypted-text/DecryptedText';
// Базовый — дешифровка при наведении
<DecryptedText text="Hover me" animateOn="hover" />
// Последовательно из центра при появлении в области видимости
<DecryptedText
text="Revealed from center"
animateOn="view"
sequential
revealDirection="center"
speed={40}
/>
// Клик с переключением (toggle)
<DecryptedText
text="Click to toggle"
animateOn="click"
clickMode="toggle"
speed={30}
maxIterations={15}
/>
// Только символы самого текста
<DecryptedText
text="ORIGINAL CHARS ONLY"
useOriginalCharsOnly
animateOn="hover"
/>
Оригинальный код
import { useEffect, useState, useRef, useMemo, useCallback } from 'react';
import { motion } from 'motion/react';
import type { HTMLMotionProps } from 'motion/react';
interface DecryptedTextProps extends HTMLMotionProps<'span'> {
text: string;
speed?: number;
maxIterations?: number;
sequential?: boolean;
revealDirection?: 'start' | 'end' | 'center';
useOriginalCharsOnly?: boolean;
characters?: string;
className?: string;
encryptedClassName?: string;
parentClassName?: string;
animateOn?: 'view' | 'hover' | 'inViewHover' | 'click';
clickMode?: 'once' | 'toggle';
}
type Direction = 'forward' | 'reverse';
export default function DecryptedText({
text,
speed = 50,
maxIterations = 10,
sequential = false,
revealDirection = 'start',
useOriginalCharsOnly = false,
characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!@#$%^&*()_+',
className = '',
parentClassName = '',
encryptedClassName = '',
animateOn = 'hover',
clickMode = 'once',
...props
}: DecryptedTextProps) {
const [displayText, setDisplayText] = useState<string>(text);
const [isAnimating, setIsAnimating] = useState<boolean>(false);
const [revealedIndices, setRevealedIndices] = useState<Set<number>>(new Set());
const [hasAnimated, setHasAnimated] = useState<boolean>(false);
const [isDecrypted, setIsDecrypted] = useState<boolean>(animateOn !== 'click');
const [direction, setDirection] = useState<Direction>('forward');
const containerRef = useRef<HTMLSpanElement>(null);
const orderRef = useRef<number[]>([]);
const pointerRef = useRef<number>(0);
const availableChars = useMemo<string[]>(() => {
return useOriginalCharsOnly
? Array.from(new Set(text.split(''))).filter(char => char !== ' ')
: characters.split('');
}, [useOriginalCharsOnly, text, characters]);
const shuffleText = useCallback(
(originalText: string, currentRevealed: Set<number>) => {
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<number> => {
const s = new Set<number>();
for (let i = 0; i < text.length; i++) s.add(i);
return s;
}, [text]);
const removeRandomIndices = useCallback((set: Set<number>, count: number): Set<number> => {
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<number>();
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<typeof setInterval>;
let currentIteration = 0;
const getNextIndex = (revealedSet: Set<number>): number => {
const textLength = text.length;
switch (revealDirection) {
case 'start':
return revealedSet.size;
case 'end':
return textLength - 1 - revealedSet.size;
case 'center': {
const middle = Math.floor(textLength / 2);
const offset = Math.floor(revealedSet.size / 2);
const nextIndex = revealedSet.size % 2 === 0 ? middle + offset : middle - offset - 1;
if (nextIndex >= 0 && nextIndex < textLength && !revealedSet.has(nextIndex)) {
return nextIndex;
}
for (let i = 0; i < textLength; i++) {
if (!revealedSet.has(i)) return i;
}
return 0;
}
default:
return revealedSet.size;
}
};
interval = setInterval(() => {
setRevealedIndices(prevRevealed => {
if (sequential) {
// Forward
if (direction === 'forward') {
if (prevRevealed.size < text.length) {
const nextIndex = getNextIndex(prevRevealed);
const newRevealed = new Set(prevRevealed);
newRevealed.add(nextIndex);
setDisplayText(shuffleText(text, newRevealed));
return newRevealed;
} else {
clearInterval(interval);
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 {
// Non-Sequential
if (direction === 'forward') {
setDisplayText(shuffleText(text, prevRevealed));
currentIteration++;
if (currentIteration >= maxIterations) {
clearInterval(interval);
setIsAnimating(false);
setDisplayText(text);
setIsDecrypted(true);
}
return prevRevealed;
}
// 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();
}
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 !== 'inViewHover') return;
const observerCallback = (entries: IntersectionObserverEntry[]) => {
entries.forEach(entry => {
if (entry.isIntersecting && !hasAnimated) {
triggerDecrypt();
setHasAnimated(true);
}
});
};
const observerOptions = {
root: null,
rootMargin: '0px',
threshold: 0.1
};
const observer = new IntersectionObserver(observerCallback, observerOptions);
const currentRef = containerRef.current;
if (currentRef) {
observer.observe(currentRef);
}
return () => {
if (currentRef) observer.unobserve(currentRef);
};
}, [animateOn, hasAnimated, triggerDecrypt]);
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: triggerHoverDecrypt,
onMouseLeave: resetToPlainText
}
: animateOn === 'click'
? {
onClick: handleClick
}
: {};
return (
<motion.span
ref={containerRef}
className={`inline-block whitespace-pre-wrap ${parentClassName}`}
{...animateProps}
{...props}
>
<span className="sr-only">{displayText}</span>
<span aria-hidden="true">
{displayText.split('').map((char, index) => {
const isRevealedOrDone = revealedIndices.has(index) || (!isAnimating && isDecrypted);
return (
<span key={index} className={isRevealedOrDone ? className : encryptedClassName}>
{char}
</span>
);
})}
</span>
</motion.span>
);
}
Пропсы
| Проп |
Тип |
По умолчанию |
Описание |
text |
string |
'Decrypted Text' |
Текст для дешифровки |
animateOn |
'hover' | 'view' | 'inViewHover' | 'click' |
'hover' |
Триггер анимации |
speed |
number |
50 |
Интервал между шагами в мс |
maxIterations |
number |
10 |
Итераций шифрования (без sequential) |
sequential |
boolean |
false |
Раскрывать символы по одному |
revealDirection |
'start' | 'end' | 'center' |
'start' |
Направление раскрытия (только sequential) |
useOriginalCharsOnly |
boolean |
false |
Шифровать только символами исходного текста |
characters |
string |
латиница + спецсимволы |
Набор символов для шифрования |
clickMode |
'once' | 'toggle' |
'once' |
Режим клика (только animateOn=click) |
className |
string |
'' |
CSS классы для раскрытых символов |
encryptedClassName |
string |
'' |
CSS классы для зашифрованных символов |
parentClassName |
string |
'' |
CSS классы на контейнере |