打字机
一句句文字在闪烁的光标后逐字打出,停顿后删除,再继续下一句。
用 React 制作一个打字机效果,循环显示“把想法做出来。”“把细节打磨好。”“复制提示词。”三句话。每 55ms 打出一个字,完整显示后停留 1.4 秒,再每 30ms 删除一个字,然后开始下一句。用一条 setTimeout 链驱动,并在组件卸载时清除。文字后面显示一个 2px 宽的光标,用 CSS steps(1) 动画每 1 秒闪烁一次,打字过程中停止闪烁。用 Array.from(text) 遍历字符,避免拆坏表情符号和汉字。把当前完整句子放在 aria-label 中,并对屏幕阅读器隐藏动画文字。在 prefers-reduced-motion 下直接显示整句,每 2.5 秒切换一次。
源代码
'use client'
import { useEffect, useState } from 'react'
import s from './demo.module.css'
export default function Typewriter({ labels }: { labels: Record<string, string> }) {
const [text, setText] = useState('')
const [typing, setTyping] = useState(false)
const [full, setFull] = useState(labels.phrases.split('|')[0])
useEffect(() => {
const phrases = labels.phrases.split('|')
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
let timer: ReturnType<typeof setTimeout>
let p = 0
let i = 0
let deleting = false
const tick = () => {
const chars = Array.from(phrases[p])
setFull(phrases[p])
if (reduced) {
setText(phrases[p])
p = (p + 1) % phrases.length
timer = setTimeout(tick, 2500)
return
}
if (!deleting && i < chars.length) {
i++
setTyping(true)
timer = setTimeout(tick, 55)
} else if (!deleting) {
deleting = true
setTyping(false)
timer = setTimeout(tick, 1400)
return
} else if (i > 0) {
i--
setTyping(true)
timer = setTimeout(tick, 30)
} else {
deleting = false
p = (p + 1) % phrases.length
timer = setTimeout(tick, 250)
return
}
setText(chars.slice(0, i).join(''))
}
tick()
return () => clearTimeout(timer)
}, [labels.phrases])
return (
<p className={s.line} aria-label={full}>
<span aria-hidden="true">{text}</span>
<span className={typing ? s.caret : `${s.caret} ${s.blink}`} aria-hidden="true" />
</p>
)
}
.line {
display: flex;
align-items: center;
min-height: 1.4em;
margin: 0;
font: 600 var(--text-2xl) / 1.2 var(--font-display);
letter-spacing: -0.02em;
color: var(--stage-ink);
white-space: nowrap;
}
.caret {
width: 2px;
height: 1.05em;
margin-left: 3px;
background: var(--stage-accent);
}
.blink { animation: blink 1s steps(1) infinite; }
@keyframes blink { 50% { opacity: 0; } }