wui
组件

打字机 Typewriter

带闪烁光标的逐字输入效果,依次输入、停留、删除并切换多段文本。

第三方依赖 · motion

基础用法

传入 texts 数组,组件会逐字输入第一段文本,停留片刻后逐字删除,再输入下一段。光标在输入与删除时保持常亮,停顿时闪烁:

Loading…

安装与引入

通过 CLI 自动添加组件,或手动复制源码至项目中:

pnpm dlx @wui-design/cli@latest add @wui/typewriter
安装基础依赖与 Motion 动效库
pnpm add motion clsx tailwind-merge
复制组件源码到 components/ui/typewriter.tsx
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 属性:

属性类型默认值说明
textsstring[]—依次输入的文本数组。按字素(grapheme)拆分,中文与 Emoji 都能正确逐字输入。
typeSpeednumber70输入每个字符的间隔(毫秒)。
deleteSpeednumber35删除每个字符的间隔(毫秒)。
pauseDurationnumber1600一段文本输入完成后停留的时长(毫秒)。
startDelaynumber0开始输入第一个字符前的等待时长(毫秒)。
loopbooleantrue最后一段结束后是否回到第一段继续。
keepLastbooleantrue关闭 `loop` 时,是否让最后一段文本停留在屏幕上而不再删除。
startOnViewbooleanfalse滚动进入视口后才开始输入。
cursor"bar" | "block" | "underscore" | "none""bar"光标形状:竖线、方块、下划线,或不显示光标。光标颜色跟随文字颜色。
cursorClassNamestring—应用于光标元素的额外类名,可用于调整颜色或尺寸。

事件 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 切换,光标停止闪烁。