wui
组件

文本入场 Text Effect

支持按字符、单词或行级粒度拆分,并提供多种高质感入场动效预设与自定义物理过渡的文本动效组件。

第三方依赖 · motion

基础用法

最简单的文本入场效果。文本会在组件挂载或触发状态改变时,按照指定的粒子粒度与动效预设优雅展开:

Loading…

安装与引入

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

pnpm dlx @wui-design/cli@latest add @wui/text-effect
安装依赖与 Motion 动效库
pnpm add motion clsx tailwind-merge
复制组件源码到 components/ui/text-effect.tsx
components/ui/text-effect.tsx
"use client"

import * as React from "react"
import {
  motion,
  useReducedMotion,
  type Transition,
  type Variants,
} from "motion/react"

import { cn } from "@/lib/utils"

export type TextEffectPreset =
  | "fade"
  | "blur"
  | "blur-sm"
  | "fade-in-blur"
  | "scale"
  | "slide"
  | "rise"
  | "drop"
  | "flip"

const presetVariants: Record<TextEffectPreset, Variants> = {
  fade: {
    hidden: { opacity: 0 },
    visible: { opacity: 1 },
    exit: { opacity: 0 },
  },
  blur: {
    hidden: { opacity: 0, filter: "blur(12px)" },
    visible: { opacity: 1, filter: "blur(0px)" },
    exit: { opacity: 0, filter: "blur(12px)" },
  },
  "blur-sm": {
    hidden: { opacity: 0, filter: "blur(4px)" },
    visible: { opacity: 1, filter: "blur(0px)" },
    exit: { opacity: 0, filter: "blur(4px)" },
  },
  "fade-in-blur": {
    hidden: { opacity: 0, y: 8, filter: "blur(8px)" },
    visible: { opacity: 1, y: 0, filter: "blur(0px)" },
    exit: { opacity: 0, y: -8, filter: "blur(8px)" },
  },
  scale: {
    hidden: { opacity: 0, scale: 0.85 },
    visible: { opacity: 1, scale: 1 },
    exit: { opacity: 0, scale: 0.85 },
  },
  slide: {
    hidden: { opacity: 0, y: 18 },
    visible: { opacity: 1, y: 0 },
    exit: { opacity: 0, y: -18 },
  },
  rise: {
    hidden: { opacity: 0, y: "0.6em" },
    visible: { opacity: 1, y: "0em" },
    exit: { opacity: 0, y: "-0.3em" },
  },
  drop: {
    hidden: { opacity: 0, y: "-0.6em" },
    visible: { opacity: 1, y: "0em" },
    exit: { opacity: 0, y: "0.3em" },
  },
  flip: {
    hidden: { opacity: 0, rotateX: 90, transformPerspective: 600 },
    visible: { opacity: 1, rotateX: 0, transformPerspective: 600 },
    exit: { opacity: 0, rotateX: -90, transformPerspective: 600 },
  },
}

/** Presets whose motion reads better on a spring than on a tween. */
const springPresets: Partial<Record<TextEffectPreset, Transition>> = {
  rise: { type: "spring", stiffness: 420, damping: 28, mass: 0.6 },
  drop: { type: "spring", stiffness: 420, damping: 26, mass: 0.6 },
  flip: { type: "spring", stiffness: 300, damping: 24, mass: 0.7 },
}

export interface TextEffectProps extends React.ComponentProps<"p"> {
  /** Text split into animated segments. */
  children: string
  /** Segment granularity. CJK text is animated per character in `word` mode. @default "word" */
  per?: "word" | "char" | "line"
  /** HTML element rendered by the component. @default "p" */
  as?: React.ElementType
  /** Built-in segment animation. @default "fade" */
  preset?: TextEffectPreset
  /** Custom container and segment variants. */
  variants?: { container?: Variants; item?: Variants }
  /** Delay before the reveal begins, in seconds. @default 0 */
  delay?: number
  /** Reveal or hide the text. @default true */
  trigger?: boolean
  /** Multiplier for the container stagger speed. @default 1 */
  speedReveal?: number
  /** Multiplier for each segment's animation speed. @default 1 */
  speedSegment?: number
  /** Extra class applied to every segment wrapper. */
  segmentWrapperClassName?: string
  /** Custom container transition. */
  containerTransition?: Transition
  /** Custom transition for each segment. */
  segmentTransition?: Transition
  /** Called when the reveal animation starts. */
  onAnimationStart?: () => void
  /** Called when the reveal animation completes. */
  onAnimationComplete?: () => void
}

const cjkPattern =
  /[⺀-⿿ -〿぀-ヿ㐀-䶿一-鿿가-힯豈-﫿＀-￯]/

type Token = { text: string; whitespace: boolean; chars?: string[] }

/**
 * Splits text into animation units. Whitespace is kept as plain text so the
 * browser can still wrap lines naturally; CJK runs have no spaces, so they
 * are broken per character to keep both wrapping and staggering meaningful.
 */
function tokenize(
  text: string,
  per: NonNullable<TextEffectProps["per"]>
): Token[] {
  if (per === "line") {
    return text.split("\n").map((line) => ({ text: line, whitespace: false }))
  }

  const tokens: Token[] = []
  for (const part of text.split(/(\s+)/)) {
    if (!part) continue
    if (/^\s+$/.test(part)) {
      tokens.push({ text: part, whitespace: true })
    } else if (cjkPattern.test(part)) {
      for (const char of Array.from(part)) {
        tokens.push({ text: char, whitespace: false })
      }
    } else if (per === "char") {
      tokens.push({ text: part, whitespace: false, chars: Array.from(part) })
    } else {
      tokens.push({ text: part, whitespace: false })
    }
  }
  return tokens
}

/** Reveals text by line, word or character using a built-in or custom preset. */
function TextEffect({
  children,
  per = "word",
  as = "p",
  preset = "fade",
  variants,
  delay = 0,
  trigger = true,
  speedReveal = 1,
  speedSegment = 1,
  segmentWrapperClassName,
  containerTransition,
  segmentTransition,
  className,
  onAnimationStart,
  onAnimationComplete,
  ...props
}: TextEffectProps) {
  const reduceMotion = useReducedMotion()
  const Component = React.useMemo(() => motion.create(as), [as])
  const tokens = React.useMemo(() => tokenize(children, per), [children, per])
  const stagger =
    (per === "char" ? 0.025 : per === "word" ? 0.06 : 0.12) / speedReveal
  const containerVariants: Variants = reduceMotion
    ? { hidden: {}, visible: {}, exit: {} }
    : (variants?.container ?? {
        hidden: {},
        visible: {
          transition: { staggerChildren: stagger, delayChildren: delay },
        },
        exit: {
          transition: { staggerChildren: stagger, staggerDirection: -1 },
        },
      })
  const itemVariants = variants?.item ?? presetVariants[preset]
  const itemTransition: Transition = reduceMotion
    ? { duration: 0 }
    : {
        duration: 0.4 / speedSegment,
        ease: [0.22, 1, 0.36, 1],
        ...(variants?.item ? undefined : springPresets[preset]),
        ...segmentTransition,
      }

  const segmentClassName = cn(
    per === "line" ? "block" : "inline-block",
    segmentWrapperClassName
  )

  const renderSegment = (text: string, key: string, hidden?: boolean) => (
    <motion.span
      aria-hidden={hidden || undefined}
      data-slot="text-effect-segment"
      key={key}
      className={segmentClassName}
      variants={itemVariants}
      transition={itemTransition}
    >
      {text}
    </motion.span>
  )

  return (
    <Component
      data-slot="text-effect"
      className={cn(per === "line" && "flex flex-col", className)}
      initial="hidden"
      animate={reduceMotion || trigger ? "visible" : "exit"}
      variants={containerVariants}
      transition={containerTransition}
      onAnimationStart={onAnimationStart}
      onAnimationComplete={onAnimationComplete}
      {...props}
    >
      <span className="sr-only">{children}</span>
      {tokens.map((token, index) => {
        if (token.whitespace) {
          return (
            <span aria-hidden="true" key={`space-${index}`}>
              {token.text}
            </span>
          )
        }
        if (token.chars) {
          // Keep the characters of a Latin word together while each one
          // animates on its own.
          return (
            <span
              aria-hidden="true"
              key={`word-${index}`}
              className="inline-block whitespace-nowrap"
            >
              {token.chars.map((char, charIndex) =>
                renderSegment(char, `${char}-${index}-${charIndex}`)
              )}
            </span>
          )
        }
        return renderSegment(token.text, `${token.text}-${index}`, true)
      })}
    </Component>
  )
}

export { TextEffect, presetVariants as textEffectPresets }

属性 Props

TextEffect 支持以下配置属性,并会继承底层所渲染 HTML 元素(默认 <p>)的原生属性:

属性类型默认值说明
childrenstring—待进行入场动效拆分的纯文本内容。
per"char" | "word" | "line""word"文本拆分的颗粒度:char(按字符)、word(按单词/空格)、line(按换行符 \n)。中文等 CJK 文本没有空格,word 模式下自动按字拆分;char 模式下英文单词整体不换行,避免被拆到两行。
preset"fade" | "blur" | "blur-sm" | "fade-in-blur" | "scale" | "slide" | "rise" | "drop" | "flip""fade"内置的动效预设样式。rise、drop、flip 默认使用弹簧曲线,其余为缓出补间。
asReact.ElementType"p"外层容器所渲染的原生 HTML 标签或组件(如 h1, h2, span, div 等)。
delaynumber0动画开始前的等待延迟时间(单位:秒)。
triggerbooleantrue控制动效的播放状态。切换为 false 时执行退出动效,切换为 true 时重新播放入场。
speedRevealnumber1容器中相邻片段之间交错入场(Stagger)的速度倍率,数值越大越紧凑迅速。
speedSegmentnumber1单个片段自身动画播放的时长倍率,数值越大单个片段出现得越快。
variants{ container?: Variants; item?: Variants }—自定义 Motion 动画变体,可完全覆盖内置的容器与片段动画配置。
containerTransitionTransition—应用于外层容器的自定义 Transition 过渡配置。
segmentTransitionTransition—应用于各个片段子元素的自定义 Transition 过渡配置。
segmentWrapperClassNamestring—应用于各个片段包裹 span 元素的额外 CSS 类名。
classNamestring—应用于最外层容器元素的额外 CSS 类名。

事件 Events

属性类型默认值说明
onAnimationStart() => void—整段入场动画开始播放时触发的回调函数。
onAnimationComplete() => void—所有拆分片段的动画全部完成并落定后触发的回调函数。

使用场景与设计规范

TextEffect 适用于为关键视觉中心赋予动感与生命力,多用于 Landing Page Hero 标题、功能上线公告卡片、AI 回答生成标题以及关键指标披露。

  • 克制使用原则:动效应用来引导视线与强化核心信息,切勿在长篇正文或大段说明中使用过慢的逐字动效,以免阻碍用户快速获取信息。
  • 选择合适的拆分颗粒度:
    • per="char"(字符级):适合极短的品牌词(3~6 个单词或短中文词汇),节奏明快富有现代感。
    • per="word"(单词级):最推荐的通用粒度,适合 1~2 行的 Hero 标语或特性小标题。
    • per="line"(行级):适合 3 行以内的列表、诗歌式排版或分条目要点展示。
  • 与用户操作联动:结合 trigger 与 onAnimationComplete,可以在前置动画落定后链式解锁下一阶段的交互控件(如展示操作按钮或下一段文字)。

场景示例

动效预设

组件内置了 9 种精调的动效预设,满足不同设计语言的需求,切换后点击文字可重播:

Loading…
  • fade-in-blur:结合模糊消退与微量位移,极具高级感与科技质感,推荐作为产品主标题默认效果。
  • fade:最稳重的透明度淡入,适合严谨的企业级控制台界面。
  • blur-sm:高斯模糊从 4px 快速对焦至清晰,适合短促的标签与状态。
  • scale:轻微缩放入场,赋予文字活泼的弹跳感。
  • slide:纵向位移滑入,非常契合列表项与通知流。
  • blur:由 12px 强模糊对焦到清晰,适合沉浸式首屏标语。
  • rise / drop:逐字从下方弹起或从上方落下,位移以 em 计算,随字号自动缩放。
  • flip:沿 X 轴翻转立起,带透视,适合品牌字与大标题。

拆分颗粒度比较

通过 per 属性控制文本拆分为字符、单词或换行片段:

Loading…

自定义 Motion 动画变体

如果你需要更具冲击力的 3D 翻转、弹性阻尼或非线性路径,可以通过 variants 属性传入自定义的容器与片段配置:

Loading…
const customVariants = {
  container: {
    hidden: {},
    visible: { transition: { staggerChildren: 0.06, delayChildren: 0.1 } },
  },
  item: {
    hidden: { opacity: 0, y: 24, rotateX: -60, filter: "blur(6px)" },
    visible: {
      opacity: 1,
      y: 0,
      rotateX: 0,
      filter: "blur(0px)",
      transition: { type: "spring", stiffness: 260, damping: 20 },
    },
  },
}

export function CustomTypography() {
  return (
    <TextEffect per="char" variants={customVariants} className="[perspective:800px]">
      向光而行
    </TextEffect>
  )
}

业务卡片级联入场

模拟 AI 摘要的逐层呈现:标题、正文与待办依次通过 delay 编排入场,最后一段在 onAnimationComplete 中切换底部状态:

Loading…

无障碍与交互 Accessibility

  • 屏幕阅读器友好:组件内部渲染一份视觉隐藏(sr-only)的完整文本,拆分出的 motion.span 片段统一标记为 aria-hidden="true"(相比在 p 等元素上使用 aria-label 兼容性更好)。辅助技术(如 VoiceOver、NVDA)将把整段文字作为连续完整的语义块一次性朗读,避免了屏幕阅读器逐字逐字母中断朗读的问题。
  • 系统减少动态偏好(Reduced Motion):组件内置集成 useReducedMotion()。当操作系统开启“减少动态效果(prefers-reduced-motion)”时,组件保持与服务端一致的结构,但取消交错与过渡时长,文字直接以最终状态呈现,确保低视力及前庭觉敏感用户的舒适体验。
  • 语义化标签渲染:通过 as 属性指定语义 HTML 标签(如 h1, h2, p, span),确保页面大纲结构(Outline Hierarchy)对搜索引擎与读屏工具保持准确。