wui
组件

文本形变 Text Morph

在词汇与文本发生变化时,自动追踪共有字符并基于弹性物理模型实现平滑位移与形变过渡。

第三方依赖 · motion

基础用法

最简单的文本形变用法。在按钮状态切换时,新旧文案中的相同字母将直接平滑滑移至新位置,不同字符则渐变融合:

Loading…

安装与引入

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

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

属性类型默认值说明
childrenstring—待进行形变过渡的纯文本字符串。当该值发生变化时自动触发字符追踪动画。
asReact.ElementType"p"外层容器所渲染的语义 HTML 标签(如 span, p, h3, div 等)。
transitionTransition{ type: "spring", stiffness: 380, damping: 32, mass: 0.6 }共享字符移动到新位置时使用的 Motion 过渡。
variantsVariants{ initial: { opacity: 0, filter: "blur(2px)" }, animate: { opacity: 1, filter: "blur(0px)" }, exit: { opacity: 0, filter: "blur(2px)" } }仅在一侧出现的字符的进场(initial → animate)与离场(exit)状态。
classNamestring—应用于外层容器元素的额外 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 渲染,空格宽度在形变过程中保持稳定。