Skip to content

Commit cca0993

Browse files
✨ feat: Add TypewriterEffect
1 parent b616ee3 commit cca0993

File tree

14 files changed

+709
-0
lines changed

14 files changed

+709
-0
lines changed
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
'use client';
2+
3+
import { motion } from 'framer-motion';
4+
import { createElement, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
5+
6+
import { useStyles } from './style';
7+
import type { TypewriterEffectProps } from './type';
8+
9+
const TypewriterEffect = memo<TypewriterEffectProps>(
10+
({
11+
sentences,
12+
as: Component = 'div',
13+
typingSpeed = 100,
14+
initialDelay = 0,
15+
pauseDuration = 2000,
16+
deletingSpeed = 50,
17+
loop = true,
18+
className = '',
19+
color,
20+
showCursor = true,
21+
hideCursorWhileTyping = false,
22+
cursorCharacter,
23+
cursorClassName = '',
24+
cursorColor,
25+
cursorBlinkDuration = 0.8,
26+
cursorStyle = 'pipe',
27+
textColors = [],
28+
variableSpeed,
29+
onSentenceComplete,
30+
startOnVisible = false,
31+
reverseMode = false,
32+
...props
33+
}: TypewriterEffectProps) => {
34+
const { styles, cx } = useStyles();
35+
const [displayedText, setDisplayedText] = useState('');
36+
const [currentCharIndex, setCurrentCharIndex] = useState(0);
37+
const [isDeleting, setIsDeleting] = useState(false);
38+
const [currentTextIndex, setCurrentTextIndex] = useState(0);
39+
const [isVisible, setIsVisible] = useState(!startOnVisible);
40+
const containerRef = useRef<HTMLElement>(null);
41+
42+
const textArray = useMemo(
43+
() => (Array.isArray(sentences) ? sentences : [sentences]),
44+
[sentences],
45+
);
46+
47+
const getRandomSpeed = useCallback(() => {
48+
if (!variableSpeed) return typingSpeed;
49+
const { min, max } = variableSpeed;
50+
return Math.random() * (max - min) + min;
51+
}, [variableSpeed, typingSpeed]);
52+
53+
const getCurrentTextColor = () => {
54+
if (textColors.length > 0) {
55+
return textColors[currentTextIndex % textColors.length];
56+
}
57+
return color;
58+
};
59+
60+
const getCurrentCursorColor = () => {
61+
return cursorColor || color;
62+
};
63+
64+
useEffect(() => {
65+
if (!startOnVisible || !containerRef.current) return;
66+
67+
const observer = new IntersectionObserver(
68+
(entries) => {
69+
entries.forEach((entry) => {
70+
if (entry.isIntersecting) {
71+
setIsVisible(true);
72+
}
73+
});
74+
},
75+
{ threshold: 0.1 },
76+
);
77+
78+
observer.observe(containerRef.current);
79+
80+
return () => observer.disconnect();
81+
}, [startOnVisible]);
82+
83+
useEffect(() => {
84+
if (!isVisible) return;
85+
86+
let timeout: ReturnType<typeof setTimeout>;
87+
88+
const currentText = textArray[currentTextIndex];
89+
const processedText = reverseMode ? currentText.split('').reverse().join('') : currentText;
90+
91+
const executeTypingAnimation = () => {
92+
if (isDeleting) {
93+
if (displayedText === '') {
94+
setIsDeleting(false);
95+
if (currentTextIndex === textArray.length - 1 && !loop) {
96+
return;
97+
}
98+
if (onSentenceComplete) {
99+
onSentenceComplete(textArray[currentTextIndex], currentTextIndex);
100+
}
101+
setCurrentTextIndex((prev) => (prev + 1) % textArray.length);
102+
setCurrentCharIndex(0);
103+
timeout = setTimeout(() => {}, pauseDuration);
104+
} else {
105+
timeout = setTimeout(() => {
106+
setDisplayedText((prev) => prev.slice(0, -1));
107+
}, deletingSpeed);
108+
}
109+
} else {
110+
if (currentCharIndex < processedText.length) {
111+
timeout = setTimeout(
112+
() => {
113+
setDisplayedText((prev) => prev + processedText[currentCharIndex]);
114+
setCurrentCharIndex((prev) => prev + 1);
115+
},
116+
variableSpeed ? getRandomSpeed() : typingSpeed,
117+
);
118+
} else if (textArray.length >= 1) {
119+
if (!loop && currentTextIndex === textArray.length - 1) return;
120+
121+
timeout = setTimeout(() => {
122+
setIsDeleting(true);
123+
}, pauseDuration);
124+
}
125+
}
126+
};
127+
128+
if (currentCharIndex === 0 && !isDeleting && displayedText === '') {
129+
timeout = setTimeout(executeTypingAnimation, initialDelay);
130+
} else {
131+
executeTypingAnimation();
132+
}
133+
134+
return () => clearTimeout(timeout);
135+
}, [
136+
currentCharIndex,
137+
displayedText,
138+
isDeleting,
139+
typingSpeed,
140+
deletingSpeed,
141+
pauseDuration,
142+
textArray,
143+
currentTextIndex,
144+
loop,
145+
initialDelay,
146+
isVisible,
147+
reverseMode,
148+
variableSpeed,
149+
onSentenceComplete,
150+
getRandomSpeed,
151+
]);
152+
153+
const getCursorStyle = () => {
154+
if (cursorCharacter) return styles.cursorCustom;
155+
156+
switch (cursorStyle) {
157+
case 'block': {
158+
return styles.cursorBlock;
159+
}
160+
case 'dot': {
161+
return styles.cursorDot;
162+
}
163+
case 'underscore': {
164+
return styles.cursorUnderscore;
165+
}
166+
case 'pipe': {
167+
return styles.cursor;
168+
}
169+
}
170+
};
171+
172+
const shouldHideCursor =
173+
hideCursorWhileTyping &&
174+
(currentCharIndex < textArray[currentTextIndex].length || isDeleting);
175+
176+
const textColor = getCurrentTextColor();
177+
const finalCursorColor = getCurrentCursorColor();
178+
179+
// Split displayed text into characters for animation
180+
const characters = displayedText.split('');
181+
182+
return createElement(
183+
Component,
184+
{
185+
className: cx(styles.container, className),
186+
ref: containerRef,
187+
...props,
188+
},
189+
<>
190+
<span className={styles.text} style={textColor ? { color: textColor } : undefined}>
191+
{characters.map((char, index) => (
192+
<motion.span
193+
animate={{ opacity: 1 }}
194+
initial={{ opacity: 0 }}
195+
key={`${currentTextIndex}-${index}`}
196+
style={{ display: 'inline-block' }}
197+
transition={{
198+
duration: typingSpeed / 500,
199+
ease: 'easeInOut',
200+
}}
201+
>
202+
{char === ' ' ? '\u00A0' : char}
203+
</motion.span>
204+
))}
205+
</span>
206+
{showCursor && (
207+
<motion.span
208+
animate={{ opacity: 1 }}
209+
className={cx(
210+
getCursorStyle(),
211+
cursorClassName,
212+
shouldHideCursor && styles.cursorHidden,
213+
)}
214+
initial={{ opacity: 0 }}
215+
style={finalCursorColor ? { backgroundColor: finalCursorColor } : undefined}
216+
transition={{
217+
duration: cursorBlinkDuration,
218+
ease: 'easeInOut',
219+
repeat: Number.POSITIVE_INFINITY,
220+
repeatType: 'reverse',
221+
}}
222+
>
223+
{cursorCharacter}
224+
</motion.span>
225+
)}
226+
</>,
227+
);
228+
},
229+
);
230+
231+
TypewriterEffect.displayName = 'TypewriterEffect';
232+
233+
export default TypewriterEffect;
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { TypewriterEffect } from '@lobehub/ui/awesome';
2+
import { Flexbox } from 'react-layout-kit';
3+
4+
export default () => {
5+
return (
6+
<Flexbox gap={16}>
7+
<TypewriterEffect cursorCharacter="▋" sentences={['Custom cursor character']} />
8+
<TypewriterEffect hideCursorWhileTyping sentences={['Hide cursor while typing']} />
9+
<TypewriterEffect
10+
sentences={['Blue text', 'Green text', 'Red text']}
11+
textColors={['#1677ff', '#52c41a', '#ff4d4f']}
12+
/>
13+
<TypewriterEffect loop={false} sentences={['First', 'Last (stops here)']} />
14+
</Flexbox>
15+
);
16+
};
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { TypewriterEffect } from '@lobehub/ui/awesome';
2+
import { useState } from 'react';
3+
import { Flexbox } from 'react-layout-kit';
4+
5+
export default () => {
6+
const [count, setCount] = useState(0);
7+
8+
return (
9+
<Flexbox gap={16}>
10+
<TypewriterEffect
11+
onSentenceComplete={() => setCount((c) => c + 1)}
12+
pauseDuration={1500}
13+
sentences={['First sentence', 'Second sentence', 'Third sentence']}
14+
/>
15+
<div style={{ color: '#888', fontSize: 14 }}>Completed: {count} times</div>
16+
</Flexbox>
17+
);
18+
};
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { TypewriterEffect } from '@lobehub/ui/awesome';
2+
import { Flexbox } from 'react-layout-kit';
3+
4+
export default () => {
5+
return (
6+
<Flexbox gap={16}>
7+
<TypewriterEffect color="#1677ff" sentences={['Blue text with blue cursor']} />
8+
<TypewriterEffect color="#52c41a" sentences={['Green text with green cursor']} />
9+
<TypewriterEffect
10+
color="#ff4d4f"
11+
cursorColor="#1677ff"
12+
sentences={['Red text with blue cursor']}
13+
/>
14+
<TypewriterEffect
15+
color="#722ed1"
16+
cursorColor="#52c41a"
17+
sentences={['Purple text with green cursor']}
18+
/>
19+
</Flexbox>
20+
);
21+
};
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { TypewriterEffect } from '@lobehub/ui/awesome';
2+
import { Flexbox } from 'react-layout-kit';
3+
4+
export default () => {
5+
const sentences = ['Different cursor styles'];
6+
7+
return (
8+
<Flexbox gap={16}>
9+
<TypewriterEffect cursorStyle="pipe" sentences={sentences} />
10+
<TypewriterEffect cursorStyle="block" sentences={sentences} />
11+
<TypewriterEffect cursorStyle="underscore" sentences={sentences} />
12+
<TypewriterEffect cursorStyle="dot" sentences={sentences} />
13+
</Flexbox>
14+
);
15+
};
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { TypewriterEffect } from '@lobehub/ui/awesome';
2+
import { Flexbox } from 'react-layout-kit';
3+
4+
export default () => {
5+
return (
6+
<Flexbox gap={16}>
7+
<TypewriterEffect
8+
deletingSpeed={30}
9+
pauseDuration={1000}
10+
sentences={['Fast typing speed']}
11+
typingSpeed={50}
12+
/>
13+
<TypewriterEffect sentences={['Normal typing speed']} />
14+
<TypewriterEffect
15+
deletingSpeed={100}
16+
pauseDuration={3000}
17+
sentences={['Slow typing speed']}
18+
typingSpeed={200}
19+
/>
20+
</Flexbox>
21+
);
22+
};

0 commit comments

Comments
 (0)