首頁
學習紀錄
遊戲心得影視Life書單案件檔案
Side Projects委託作品與二創互動實驗場
Kurau
百百 BLOG
首頁
學習紀錄
遊戲心得影視Life書單案件檔案
Side Projects委託作品與二創互動實驗場
Kurau

Kurau Blog

「隨心而寫,真真假假,都是我」

一個記錄生活、輸出興趣的個人空間。
遊戲、影視、閱讀、學習……每一段體驗都值得留下文字。

頁面導覽

  • 學習紀錄
  • 遊戲心得
  • 影視Life
  • 書單
  • 委託作品與二創
  • Kurau
  • 合作邀請

找到我

歡迎來 Discord 找我聊天!

“曾經發生的事不可能忘記,只是暫時想不起來而已。”-《神隱少女》

© 2026 Kurau All rights reserved

前端框架

HTML React 打字機效果

By Kurau·2023-11-03·Updated 2026-05-09·3 分鐘閱讀

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,做到「文字一個個飄上來」效果。

目錄

    ◆ 相關文章

    • Framer Motion 打造比最棒還要棒的動畫

      2026-05-09
    • Redux

      2026-05-09
    • react-app-env.d.ts

      2026-05-09
    • React-Use套件

      2026-05-09
    ← 上一篇Human套件教學下一篇 →live 2d 在網頁上

    ◆ 關於作者

    Kurau

    個人寫作 / 創作的 SoT,記錄遊戲、影視、學習與生活。

    更多 Kurau 的文章