wui
组件

文本翻滚 Text Roll

将字符拆分为双层垂直滚动轨迹,在悬停或挂载时呈现如机械翻牌或流体波浪般的逐字滚动质感。

第三方依赖 · motion

基础用法

最简单的文本翻滚用法。将鼠标悬停在文字区域上,字符将自左向右产生波浪式纵向翻转:

Loading…

安装与引入

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

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

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

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

const defaultVariants: Variants = {
  rest: { y: "0%" },
  hover: { y: "-50%" },
}

export interface TextRollProps extends React.ComponentProps<"span"> {
  /** Text rolled character by character. */
  children: string
  /** Duration of each character roll in seconds. @default 0.45 */
  duration?: number
  /** Delay for each character entering the roll. */
  getEnterDelay?: (index: number) => number
  /** Delay for each character returning to rest. */
  getExitDelay?: (index: number) => number
  /** Motion transition merged into every character. */
  transition?: Transition
  /** Rest and hover states for each character track. */
  variants?: Variants
  /**
   * What plays the roll. `"hover"` listens on the text itself, `"parent"`
   * listens on the closest link or button (padding included, plus keyboard
   * focus), and `"mount"` rolls once immediately. @default "hover"
   */
  trigger?: "hover" | "parent" | "mount"
  /** Control the rolled state from outside the component. */
  active?: boolean
}

/** Rolls a second copy of each character into view. */
function TextRoll({
  children,
  className,
  duration = 0.45,
  getEnterDelay = (index) => index * 0.025,
  getExitDelay = (index) => index * 0.02,
  transition,
  variants = defaultVariants,
  trigger = "hover",
  active: activeProp,
  onMouseEnter,
  onMouseLeave,
  ...props
}: TextRollProps) {
  const ref = React.useRef<HTMLSpanElement>(null)
  const reduceMotion = useReducedMotion()
  const [hovered, setHovered] = React.useState(false)
  const active = activeProp ?? (trigger === "mount" || hovered)

  React.useEffect(() => {
    const element = ref.current
    if (trigger !== "parent" || !element) return
    const target =
      element.parentElement?.closest<HTMLElement>(
        "a, button, [role='button'], [data-text-roll-trigger]"
      ) ?? element.parentElement
    if (!target) return

    const enter = () => setHovered(true)
    const leave = () => setHovered(false)
    const focusIn = () => {
      if (target.matches(":focus-visible")) setHovered(true)
    }
    target.addEventListener("pointerenter", enter)
    target.addEventListener("pointerleave", leave)
    target.addEventListener("focusin", focusIn)
    target.addEventListener("focusout", leave)
    return () => {
      target.removeEventListener("pointerenter", enter)
      target.removeEventListener("pointerleave", leave)
      target.removeEventListener("focusin", focusIn)
      target.removeEventListener("focusout", leave)
    }
  }, [trigger])

  return (
    <span
      ref={ref}
      data-slot="text-roll"
      data-state={active ? "rolled" : "rest"}
      className={cn("inline-flex", className)}
      onMouseEnter={(event) => {
        if (trigger === "hover") setHovered(true)
        onMouseEnter?.(event)
      }}
      onMouseLeave={(event) => {
        if (trigger === "hover") setHovered(false)
        onMouseLeave?.(event)
      }}
      {...props}
    >
      <span className="sr-only">{children}</span>
      {Array.from(children).map((character, index) => (
        <span
          aria-hidden="true"
          data-slot="text-roll-character"
          // 1.2em leaves room for ascenders and descenders (g, y, p) that a
          // 1em window would clip.
          className="inline-block h-[1.2em] overflow-hidden leading-[1.2]"
          key={`${character}-${index}`}
        >
          <motion.span
            className="flex flex-col"
            variants={variants}
            initial="rest"
            animate={reduceMotion ? "rest" : active ? "hover" : "rest"}
            transition={{
              duration,
              ease: [0.22, 1, 0.36, 1],
              delay: active ? getEnterDelay(index) : getExitDelay(index),
              ...transition,
            }}
          >
            <span className="block h-[1.2em] whitespace-pre">{character}</span>
            <span className="block h-[1.2em] whitespace-pre">{character}</span>
          </motion.span>
        </span>
      ))}
    </span>
  )
}

export { TextRoll }

属性 Props

TextRoll 支持以下配置属性,并继承原生 <span> 的全部 HTML 属性:

属性类型默认值说明
childrenstring—待进行双轨翻滚的纯文本字符串内容。
trigger"hover" | "parent" | "mount""hover"触发翻滚的时机:hover(悬停文字本身)、parent(悬停最近的按钮或链接,含内边距区域,并响应键盘 focus-visible)或 mount(挂载时自动播放)。放在按钮与导航链接内时推荐 parent。
activeboolean—受控模式下直接指定是否处于翻滚状态,优先级高于 trigger。
durationnumber0.45单个字符完成一次完整翻转运动的持续时间(单位:秒)。
getEnterDelay(index: number) => number(index) => index * 0.025计算各字符进入翻滚状态的延迟函数,通过递增 index 实现从左往右的波浪律动。
getExitDelay(index: number) => number(index) => index * 0.02计算各字符恢复常态的延迟函数。
variantsVariants{ rest: { y: "0%" }, hover: { y: "-50%" } }自定义字符轨道的 Motion 变体状态。
transitionTransition—合并注入到各个字符翻滚动画中的额外 Transition 过渡参数。
classNamestring—应用于外层 span 容器元素的额外 CSS 类名。

事件 Events

TextRoll 支持所有原生 <span> 鼠标与键盘事件,并在内部智能管理悬停状态:

属性类型默认值说明
onMouseEnter(event: React.MouseEvent<HTMLSpanElement>) => void—鼠标指针移入文本区域时触发。
onMouseLeave(event: React.MouseEvent<HTMLSpanElement>) => void—鼠标指针离开文本区域时触发。

使用场景与设计规范

TextRoll 非常适合为静态的可点击元素注入互动乐趣与现代设计质感,例如官网顶部导航链接、醒目的 CTA 操作按钮、大号核心标语与统计数字。

  • 交互暗示:翻滚效果本身具有强烈的“可交互感”。请优先将 trigger="hover" 应用于可点击的链接或按钮中,避免在纯阅读性静态正文中滥用。
  • 波浪延迟调节:通过 getEnterDelay 可以控制波浪的密集程度。短词汇可使用默认的 0.025s 步长;长词汇建议将步长缩短至 0.015s,避免右侧字符等待时间过长。
  • 单行内联排版:组件内部各字符处于 h-[1.2em] overflow-hidden 容器中,能自适应任何字号(从 text-xs 到 text-7xl),同时为 g、y、p 等字母的下伸部分留出空间,不会被裁切。

场景示例

导航栏菜单链接

在官网顶部导航中替代传统的下划线悬停。trigger="parent" 让整个链接区域和键盘聚焦都能触发:

Loading…

CTA 行动按钮

放在 Button 内部并使用 trigger="parent",悬停按钮任意位置即可翻滚,图标同步位移:

Loading…

挂载即时翻滚

在数据大屏或首屏核心指标展示中,设置 trigger="mount" 并通过 getEnterDelay 调整节奏,实现入场翻牌效果:

Loading…

无障碍与交互 Accessibility

  • 完整的屏幕阅读器文本保留:组件内部保留一份视觉隐藏(sr-only)的完整文本,内部各字符用于实现上下双层滚动的冗余 DOM 节点均设置了 aria-hidden="true"。读屏工具将直接朗读正确的纯文本,不会重复朗读两次字母。
  • 减弱动态支持:当检测到用户的系统开启 prefers-reduced-motion 时,组件将直接保持静态展示,不触发任何垂直滚动位移。