组件
打字机 Typewriter
带闪烁光标的逐字输入效果,依次输入、停留、删除并切换多段文本。
基础用法
传入 texts 数组,组件会逐字输入第一段文本,停留片刻后逐字删除,再输入下一段。光标在输入与删除时保持常亮,停顿时闪烁:
Loading…
安装与引入
通过 CLI 自动添加组件,或手动复制源码至项目中:
pnpm dlx @wui-design/cli@latest add @wui/typewriter安装基础依赖与 Motion 动效库
pnpm add motion clsx tailwind-merge复制组件源码到
components/ui/typewriter.tsx"use client"
import * as React from "react"
import { motion, useInView, useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"
export type TypewriterCursor = "bar" | "block" | "underscore" | "none"
export interface TypewriterProps
extends Omit<React.ComponentProps<"span">, "children"> {
/** Strings typed one after another. */
texts: string[]
/** Milliseconds spent typing each character. @default 70 */
typeSpeed?: number
/** Milliseconds spent deleting each character. @default 35 */
deleteSpeed?: number
/** Milliseconds a fully typed string stays before it is deleted. @default 1600 */
pauseDuration?: number
/** Milliseconds to wait before typing the first character. @default 0 */
startDelay?: number
/** Start over from the first string after the last one. @default true */
loop?: boolean
/** Keep the last string on screen instead of deleting it when `loop` is off. @default true */
keepLast?: boolean
/** Wait until the element scrolls into view before typing. @default false */
startOnView?: boolean
/** Caret shape. `none` hides the caret. @default "bar" */
cursor?: TypewriterCursor
/** Class applied to the caret element. */
cursorClassName?: string
/** Called after a string is fully typed, with its index. */
onTyped?: (index: number) => void
/** Called once when the last string is typed and `loop` is off. */
onComplete?: () => void
}
type Phase = "idle" | "typing" | "pausing" | "deleting" | "done"
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" })
function toGraphemes(text: string) {
return Array.from(graphemes.segment(text), (part) => part.segment)
}
const cursorShape: Record<Exclude<TypewriterCursor, "none">, string> = {
bar: "ml-[0.08em] h-[1.1em] w-[2px] translate-y-[0.12em]",
block: "ml-[0.08em] h-[1.1em] w-[0.55em] translate-y-[0.12em]",
underscore: "ml-[0.06em] h-[2px] w-[0.6em]",
}
/**
* Types strings character by character with a caret, then deletes them and
* types the next one.
*/
function Typewriter({
texts,
typeSpeed = 70,
deleteSpeed = 35,
pauseDuration = 1600,
startDelay = 0,
loop = true,
keepLast = true,
startOnView = false,
cursor = "bar",
cursorClassName,
onTyped,
onComplete,
className,
...props
}: TypewriterProps) {
const ref = React.useRef<HTMLSpanElement>(null)
const reduceMotion = useReducedMotion()
const inView = useInView(ref, { once: true })
const [textIndex, setTextIndex] = React.useState(0)
const [length, setLength] = React.useState(0)
const [phase, setPhase] = React.useState<Phase>("idle")
const characters = React.useMemo(
() => toGraphemes(texts[textIndex]),
[texts, textIndex]
)
const callbacks = React.useRef({ onTyped, onComplete })
React.useEffect(() => {
callbacks.current = { onTyped, onComplete }
})
const isLast = textIndex === texts.length - 1
const canStart = !startOnView || inView
React.useEffect(() => {
if (phase !== "idle" || !canStart) return
const timer = window.setTimeout(() => setPhase("typing"), startDelay)
return () => window.clearTimeout(timer)
}, [canStart, phase, startDelay])
React.useEffect(() => {
if (phase === "typing") {
if (reduceMotion) {
setLength(characters.length)
setPhase("pausing")
callbacks.current.onTyped?.(textIndex)
return
}
if (length < characters.length) {
const timer = window.setTimeout(() => setLength(length + 1), typeSpeed)
return () => window.clearTimeout(timer)
}
callbacks.current.onTyped?.(textIndex)
if (isLast && !loop && keepLast) {
setPhase("done")
callbacks.current.onComplete?.()
return
}
setPhase("pausing")
return
}
if (phase === "pausing") {
const timer = window.setTimeout(() => {
if (reduceMotion) {
if (isLast && !loop) {
setPhase("done")
callbacks.current.onComplete?.()
return
}
setTextIndex((textIndex + 1) % texts.length)
setLength(0)
setPhase("typing")
return
}
setPhase("deleting")
}, pauseDuration)
return () => window.clearTimeout(timer)
}
if (phase === "deleting") {
if (length > 0) {
const timer = window.setTimeout(() => setLength(length - 1), deleteSpeed)
return () => window.clearTimeout(timer)
}
if (isLast && !loop) {
setPhase("done")
callbacks.current.onComplete?.()
return
}
setTextIndex((textIndex + 1) % texts.length)
setPhase("typing")
}
}, [
characters.length,
deleteSpeed,
isLast,
keepLast,
length,
loop,
pauseDuration,
phase,
reduceMotion,
textIndex,
texts.length,
typeSpeed,
])
const visible = characters.slice(0, length).join("")
const blinking = phase === "idle" || phase === "pausing" || phase === "done"
return (
<span
ref={ref}
data-slot="typewriter"
className={cn("inline-flex items-baseline whitespace-pre-wrap", className)}
{...props}
>
<span className="sr-only" aria-live="polite">
{texts[textIndex]}
</span>
<span aria-hidden="true" data-slot="typewriter-text">
{visible}
{cursor === "none" ? null : (
<motion.span
data-slot="typewriter-cursor"
className={cn(
"inline-block bg-current align-baseline",
cursorShape[cursor],
cursorClassName
)}
animate={
blinking && !reduceMotion
? { opacity: [1, 1, 0, 0] }
: { opacity: 1 }
}
transition={
blinking && !reduceMotion
? {
duration: 1,
times: [0, 0.5, 0.5, 1],
repeat: Infinity,
ease: "linear",
}
: { duration: 0 }
}
/>
)}
</span>
</span>
)
}
export { Typewriter }
属性 Props
Typewriter 支持以下配置属性,并继承原生 <span> 的其余 HTML 属性:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| texts | string[] | — | 依次输入的文本数组。按字素(grapheme)拆分,中文与 Emoji 都能正确逐字输入。 |
| typeSpeed | number | 70 | 输入每个字符的间隔(毫秒)。 |
| deleteSpeed | number | 35 | 删除每个字符的间隔(毫秒)。 |
| pauseDuration | number | 1600 | 一段文本输入完成后停留的时长(毫秒)。 |
| startDelay | number | 0 | 开始输入第一个字符前的等待时长(毫秒)。 |
| loop | boolean | true | 最后一段结束后是否回到第一段继续。 |
| keepLast | boolean | true | 关闭 `loop` 时,是否让最后一段文本停留在屏幕上而不再删除。 |
| startOnView | boolean | false | 滚动进入视口后才开始输入。 |
| cursor | "bar" | "block" | "underscore" | "none" | "bar" | 光标形状:竖线、方块、下划线,或不显示光标。光标颜色跟随文字颜色。 |
| cursorClassName | string | — | 应用于光标元素的额外类名,可用于调整颜色或尺寸。 |
事件 Events
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| onTyped | (index: number) => void | — | 一段文本完整输入后触发,参数为该段文本的下标。 |
| onComplete | () => void | — | 关闭 `loop` 时,全部文本播放完毕后触发一次。 |
使用场景与设计规范
Typewriter 适合营销页标语、空输入框的示例提问、命令行演示与 AI 助手的引导语。
- 控制总时长:单段文本建议不超过 20 个汉字,
typeSpeed保持在 50–90 毫秒,否则用户需要等待太久才能读到完整信息。 - 不要承载关键信息:打字过程中文字不完整,价格、错误原因等关键内容应直接静态展示。
- 避免布局跳动:放在居中标题中时,建议让打字机独占一行,或为容器设置最小宽度,防止整行随输入左右晃动。
场景示例
光标样式
bar、block 与 underscore 三种光标,适配不同的排版与终端风格:
Loading…
输入框示例提问
输入框为空时,用打字机轮播示例问题;用户开始输入后隐藏。此处给打字机设置了 aria-hidden,并用 aria-describedby 提供静态说明:
Loading…
无障碍与交互 Accessibility
- 完整文本播报:逐字变化的可见文本设置了
aria-hidden,组件另外输出一个aria-live="polite"的sr-only节点,只在切换到新一段文本时播报一次完整内容,不会逐字朗读。 - 装饰用途时隐藏:作为占位提示等纯装饰用途时,给组件加上
aria-hidden="true",同时隐藏其中的播报节点。 - 减少动态效果:开启
prefers-reduced-motion时不再逐字输入与删除,每段文本直接完整显示,按pauseDuration切换,光标停止闪烁。