跳到主要內容
← 回到學習

學習 · 前端框架

HTML React 打字機效果

目錄 · 6 個段落
  1. 01完整實作
  2. 02配套 CSS
  3. 03狀態流程圖
  4. 04常見變體
  5. 05替代:用套件
  6. 06進階:配 Framer Motion 做更花俏的版本

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>
  );
}
tsx

配套 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;
}
scss

重點:

  • 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]);
tsx

隨機速度(更像真人)

const typeSpeed = 100 + Math.random() * 100;  // 100-200ms 隨機
tsx

支援逆向打字(刪到一半就停)

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]);
tsx

這版本比較通用,任何字串切換都會自動補間。


替代:用套件

npm install typewriter-effect       # 老牌,簡單
npm install react-typed             # 包裝 typed.js
npm install react-simple-typewriter # 輕量,簡潔 API
bash
import Typewriter from 'typewriter-effect';
 
<Typewriter
  options={{
    strings: ['Hello', 'World', 'こんにちは'],
    autoStart: true,
    loop: true,
  }}
/>
tsx

自己寫的好處:

  • 完全控制游標樣式、停留時間、特殊行為(點擊變 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>
tsx

每個字元淡入,搭配 AnimatePresence、stagger,做到「文字一個個飄上來」效果。

目錄