HTML React 打字機效果
React 打字機效果(Typewriter)
TL;DR純 React state + setTimeout 實作。核心是 三個 state:typedText(目前顯示)/ currentTextIndex(輪到第幾個字串)/ isDeleting(打字中還是刪除中)。useEffect 串起時序,每次 typedText 變化觸發下一步。
想直接看效果?下面就是一個跑起來的打字機(進場才載入、
prefers-reduced-motion會停):
完整實作
import { useState, useEffect, useRef } from 'react';
import styles from './index.module.scss';
export default function TextLooper() {
const [currentTextIndex, setCurrentTextIndex] = useState(0);
const [typedText, setTypedText] = useState('');
const [isTyping, setIsTyping] = useState(true); // 是否繼續循環
const [isDeleting, setIsDeleting] = useState(false); // 打字 vs 刪除
const inputRef = useRef<HTMLInputElement>(null);
const texts = ['Hello World', 'Welcome', 'こんにちは', '안녕하세요'];
useEffect(() => {
if (texts.length === 0 || !isTyping) return;
let timeoutId: NodeJS.Timeout;
if (isDeleting) {
// 刪除階段
if (typedText === '') {
// 刪完了 → 切到下一個字串、開始打字
setIsDeleting(false);
setCurrentTextIndex((prev) => (prev + 1) % texts.length);
} else {
timeoutId = setTimeout(() => {
setTypedText(typedText.slice(0, -1));
}, 100); // 刪除速度比打字快
}
} else {
// 打字階段
if (typedText === texts[currentTextIndex]) {
// 打完整個字串 → 停一下再開始刪
timeoutId = setTimeout(() => {
setIsDeleting(true);
}, 1500); // 停留時間
} else {
timeoutId = setTimeout(() => {
setTypedText(texts[currentTextIndex].slice(0, typedText.length + 1));
}, 200); // 打字速度
}
}
return () => clearTimeout(timeoutId);
}, [typedText, currentTextIndex, texts, isDeleting, isTyping]);
const handleTextClick = (text: string) => {
if (inputRef.current) {
inputRef.current.style.display = 'block';
inputRef.current.focus();
inputRef.current.value = text;
setIsTyping(false); // 點擊後停止動畫
}
};
return (
<div className={styles.container}>
<div className={styles.textContainer}>
<div
className={styles.text}
onClick={() => handleTextClick(texts[currentTextIndex])}
>
{typedText}
<span className={styles.cursor} />
</div>
<input className={styles.input} type="text" ref={inputRef} />
</div>
</div>
);
}
配套 CSS
@import "@/styles/variables.scss";
.container { /* 容器 */ }
.textContainer {
position: relative;
}
.text {
height: 1rem;
line-height: 1rem;
}
/* 閃爍游標 */
.cursor {
display: inline-block;
width: 2px;
height: 1rem;
vertical-align: bottom;
background-color: currentColor;
margin-left: 2px;
animation: blink 1s steps(1) infinite;
}
@keyframes blink {
50% { background-color: transparent; }
}
/* 點擊變編輯框 */
.input {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 1rem;
display: none;
outline: none;
border: none;
background-color: white;
color: black;
}
重點:
steps(1)而非 linear:游標突然亮 / 突然暗,真實打字機感vertical-align: bottom對齊文字底部- 編輯時
<input>絕對定位疊在文字上,點擊切換顯示
狀態流程圖
┌─────────────────────────────────────┐
│ isDeleting=false, typedText='' │ <─── 起始
└────────────────┬────────────────────┘
│ +1 char (200ms)
↓
┌─────────────────────────────────────┐
│ isDeleting=false, 打字中 │
└────────────────┬────────────────────┘
│ typedText = texts[i]
│ 等 1500ms
↓
┌─────────────────────────────────────┐
│ isDeleting=true, 刪除中 │
└────────────────┬────────────────────┘
│ -1 char (100ms)
│ typedText === ''
↓
┌─────────────────────────────────────┐
│ i = (i+1) % texts.length │
│ isDeleting=false, 回到打字 │
└─────────────────────────────────────┘
useEffect 的依賴陣列 是讓這個循環動起來的關鍵 —— 每次 typedText 變化都觸發下一輪。
常見變體
不刪除,只 fade 切換
useEffect(() => {
if (typedText === texts[currentTextIndex]) {
const timer = setTimeout(() => {
setCurrentTextIndex((i) => (i + 1) % texts.length);
setTypedText(''); // 直接清空,不 stagger 刪除
}, 2000);
return () => clearTimeout(timer);
}
const timer = setTimeout(() => {
setTypedText(texts[currentTextIndex].slice(0, typedText.length + 1));
}, 100);
return () => clearTimeout(timer);
}, [typedText, currentTextIndex]);
隨機速度(更像真人)
const typeSpeed = 100 + Math.random() * 100; // 100-200ms 隨機
支援逆向打字(刪到一半就停)
const [target, setTarget] = useState(texts[0]);
// 外部觸發:setTarget('新字串') → useEffect 自動處理
useEffect(() => {
if (typedText === target) return;
const isDelete = !target.startsWith(typedText);
const next = isDelete
? typedText.slice(0, -1)
: target.slice(0, typedText.length + 1);
const timer = setTimeout(() => setTypedText(next), 100);
return () => clearTimeout(timer);
}, [typedText, target]);
這版本比較通用,任何字串切換都會自動補間。
替代:用套件
npm install typewriter-effect # 老牌,簡單
npm install react-typed # 包裝 typed.js
npm install react-simple-typewriter # 輕量,簡潔 API
import Typewriter from 'typewriter-effect';
<Typewriter
options={{
strings: ['Hello', 'World', 'こんにちは'],
autoStart: true,
loop: true,
}}
/>
自己寫的好處:
- 完全控制游標樣式、停留時間、特殊行為(點擊變 input)
- 不增加 bundle
- 學會核心 pattern 可以套用到其他「逐步揭露」UI(progress text、loader copy)
套件的好處:
- 不用維護
- API 多(暫停 / 跳過 / 鏈式呼叫)
進階:配 Framer Motion 做更花俏的版本
import { motion } from 'framer-motion';
<motion.span
key={typedText} // 每次變化都 re-mount
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
{typedText}
</motion.span>
每個字元淡入,搭配 AnimatePresence、stagger,做到「文字一個個飄上來」效果。