组件
文本形变 Text Morph
在词汇与文本发生变化时,自动追踪共有字符并基于弹性物理模型实现平滑位移与形变过渡。
基础用法
最简单的文本形变用法。在按钮状态切换时,新旧文案中的相同字母将直接平滑滑移至新位置,不同字符则渐变融合:
Loading…
安装与引入
通过 CLI 自动添加组件,或手动复制源码至项目中:
pnpm dlx @wui-design/cli@latest add @wui/text-morph安装依赖与 Motion 动效库
pnpm add motion clsx tailwind-merge复制组件源码到
components/ui/text-morph.tsx"use client"
import * as React from "react"
import {
AnimatePresence,
LayoutGroup,
motion,
useReducedMotion,
type Transition,
type Variants,
} from "motion/react"
import { cn } from "@/lib/utils"
const defaultTransition: Transition = {
type: "spring",
stiffness: 380,
damping: 32,
mass: 0.6,
}
const defaultVariants: Variants = {
initial: { opacity: 0, filter: "blur(2px)" },
animate: { opacity: 1, filter: "blur(0px)" },
exit: { opacity: 0, filter: "blur(2px)" },
}
export interface TextMorphProps extends React.ComponentProps<"p"> {
/** Text whose shared characters morph between positions. */
children: string
/** HTML element rendered by the component. @default "p" */
as?: React.ElementType
/** Spring or tween used to move shared characters. */
transition?: Transition
/** Initial, animate and exit states for characters that appear or leave. */
variants?: Variants
}
function characterKeys(text: string) {
const seen = new Map<string, number>()
return Array.from(text).map((character) => {
const count = seen.get(character) ?? 0
seen.set(character, count + 1)
return { character, key: `${character}-${count}` }
})
}
/**
* Morphs shared characters into their new positions when the text changes,
* while characters that only exist on one side fade in or out.
*/
function TextMorph({
children,
as = "p",
transition = defaultTransition,
variants = defaultVariants,
className,
...props
}: TextMorphProps) {
const reduceMotion = useReducedMotion()
const Component = as
const groupId = React.useId()
const characters = React.useMemo(() => characterKeys(children), [children])
const resolvedTransition = reduceMotion ? { duration: 0 } : transition
return (
<Component
data-slot="text-morph"
className={cn("relative inline-flex", className)}
{...props}
>
<span className="sr-only">{children}</span>
{/* A per-instance group keeps layout ids from jumping between morphs. */}
<LayoutGroup id={groupId}>
<AnimatePresence mode="popLayout" initial={false}>
{characters.map(({ character, key }) => (
<motion.span
aria-hidden="true"
data-slot="text-morph-character"
className="inline-block whitespace-pre"
layoutId={key}
key={key}
variants={variants}
initial="initial"
animate="animate"
exit="exit"
transition={resolvedTransition}
>
{character}
</motion.span>
))}
</AnimatePresence>
</LayoutGroup>
</Component>
)
}
export { TextMorph }
属性 Props
TextMorph 支持以下配置属性,并继承底层 HTML 元素(默认 <p>)的全部属性:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| children | string | — | 待进行形变过渡的纯文本字符串。当该值发生变化时自动触发字符追踪动画。 |
| as | React.ElementType | "p" | 外层容器所渲染的语义 HTML 标签(如 span, p, h3, div 等)。 |
| transition | Transition | { type: "spring", stiffness: 380, damping: 32, mass: 0.6 } | 共享字符移动到新位置时使用的 Motion 过渡。 |
| variants | Variants | { initial: { opacity: 0, filter: "blur(2px)" }, animate: { opacity: 1, filter: "blur(0px)" }, exit: { opacity: 0, filter: "blur(2px)" } } | 仅在一侧出现的字符的进场(initial → animate)与离场(exit)状态。 |
| className | string | — | 应用于外层容器元素的额外 CSS 类名。 |
事件 Events
TextMorph 会将 {...props} 展开到底层元素,因此支持所有原生事件:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| onClick | (event: React.MouseEvent) => void | — | 点击文本区域时触发的原生点击事件。 |
使用场景与设计规范
TextMorph 专为微交互中的文字状态转换而设计,尤其适合操作反馈、分段控制、步骤进度与搜索引导。
- 词汇相关性:形变效果在“新旧文案共享部分相同字符”时最具视觉魔力(例如
保存草稿➜正在保存草稿➜草稿已保存,或Download➜Downloaded)。如果两段文案完全无相同字符,形变将自然退化为字符淡入淡出。 - 避免过长段落形变:仅将
TextMorph用于短句、单词或标题(建议小于 40 个字符)。对几百字的文章正文执行字符级全局追踪会消耗不必要的布局计算。 - 与按钮/徽章深度融合:在提交按钮中使用
TextMorph,可以避免按钮在加载和成功状态之间发生突兀的白屏跳闪,提供原生应用级的精致触感。
场景示例
状态操作按钮
结合异步保存的三个状态,共享的“保存草稿”四个字在按钮内滑动换位,新增的字淡入:
Loading…
分段筛选标签栏
切换任务筛选时,下方统计文案中共享的“个任务”原地滑动,数字与状态词淡入淡出:
Loading…
智能搜索提示轮播
在 AI 提问框中轮换占位提示,共同的前缀“让 AI 帮你”保持稳定,只有后半句发生形变;用户开始输入后停止轮换:
Loading…
部署流水线状态
展示长耗时任务在不同阶段之间的递进演进:
Loading…
无障碍与交互 Accessibility
- 屏幕阅读器支持:组件内部渲染一份视觉隐藏(
sr-only)的完整文本,各个独立的形变motion.span标记有aria-hidden="true",保证辅助技术始终将文本作为完整词语朗读,不会因单个字母的位移而产生碎片化发音。 - 系统减少动态偏好(Reduced Motion):当用户系统启用了“减少动态效果(prefers-reduced-motion)”时,组件保持相同结构但过渡时长归零,文本直接更新。
- 实例隔离:每个
TextMorph使用独立的LayoutGroup,页面上同时存在多个实例时,相同字符不会在不同实例之间“飞来飞去”。 - 空格与排版保护:每个字符以
whitespace-pre渲染,空格宽度在形变过程中保持稳定。