文本入场 Text Effect
支持按字符、单词或行级粒度拆分,并提供多种高质感入场动效预设与自定义物理过渡的文本动效组件。
基础用法
最简单的文本入场效果。文本会在组件挂载或触发状态改变时,按照指定的粒子粒度与动效预设优雅展开:
安装与引入
通过 CLI 自动添加组件,或手动复制源码至项目中:
pnpm dlx @wui-design/cli@latest add @wui/text-effectpnpm add motion clsx tailwind-mergecomponents/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>)的原生属性:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| children | string | — | 待进行入场动效拆分的纯文本内容。 |
| 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 默认使用弹簧曲线,其余为缓出补间。 |
| as | React.ElementType | "p" | 外层容器所渲染的原生 HTML 标签或组件(如 h1, h2, span, div 等)。 |
| delay | number | 0 | 动画开始前的等待延迟时间(单位:秒)。 |
| trigger | boolean | true | 控制动效的播放状态。切换为 false 时执行退出动效,切换为 true 时重新播放入场。 |
| speedReveal | number | 1 | 容器中相邻片段之间交错入场(Stagger)的速度倍率,数值越大越紧凑迅速。 |
| speedSegment | number | 1 | 单个片段自身动画播放的时长倍率,数值越大单个片段出现得越快。 |
| variants | { container?: Variants; item?: Variants } | — | 自定义 Motion 动画变体,可完全覆盖内置的容器与片段动画配置。 |
| containerTransition | Transition | — | 应用于外层容器的自定义 Transition 过渡配置。 |
| segmentTransition | Transition | — | 应用于各个片段子元素的自定义 Transition 过渡配置。 |
| segmentWrapperClassName | string | — | 应用于各个片段包裹 span 元素的额外 CSS 类名。 |
| className | string | — | 应用于最外层容器元素的额外 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 种精调的动效预设,满足不同设计语言的需求,切换后点击文字可重播:
fade-in-blur:结合模糊消退与微量位移,极具高级感与科技质感,推荐作为产品主标题默认效果。fade:最稳重的透明度淡入,适合严谨的企业级控制台界面。blur-sm:高斯模糊从 4px 快速对焦至清晰,适合短促的标签与状态。scale:轻微缩放入场,赋予文字活泼的弹跳感。slide:纵向位移滑入,非常契合列表项与通知流。blur:由 12px 强模糊对焦到清晰,适合沉浸式首屏标语。rise/drop:逐字从下方弹起或从上方落下,位移以 em 计算,随字号自动缩放。flip:沿 X 轴翻转立起,带透视,适合品牌字与大标题。
拆分颗粒度比较
通过 per 属性控制文本拆分为字符、单词或换行片段:
自定义 Motion 动画变体
如果你需要更具冲击力的 3D 翻转、弹性阻尼或非线性路径,可以通过 variants 属性传入自定义的容器与片段配置:
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 中切换底部状态:
无障碍与交互 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)对搜索引擎与读屏工具保持准确。