组件
文本翻滚 Text Roll
将字符拆分为双层垂直滚动轨迹,在悬停或挂载时呈现如机械翻牌或流体波浪般的逐字滚动质感。
基础用法
最简单的文本翻滚用法。将鼠标悬停在文字区域上,字符将自左向右产生波浪式纵向翻转:
Loading…
安装与引入
通过 CLI 自动添加组件,或手动复制源码至项目中:
pnpm dlx @wui-design/cli@latest add @wui/text-roll安装依赖与 Motion 动效库
pnpm add motion clsx tailwind-merge复制组件源码到
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 属性:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| children | string | — | 待进行双轨翻滚的纯文本字符串内容。 |
| trigger | "hover" | "parent" | "mount" | "hover" | 触发翻滚的时机:hover(悬停文字本身)、parent(悬停最近的按钮或链接,含内边距区域,并响应键盘 focus-visible)或 mount(挂载时自动播放)。放在按钮与导航链接内时推荐 parent。 |
| active | boolean | — | 受控模式下直接指定是否处于翻滚状态,优先级高于 trigger。 |
| duration | number | 0.45 | 单个字符完成一次完整翻转运动的持续时间(单位:秒)。 |
| getEnterDelay | (index: number) => number | (index) => index * 0.025 | 计算各字符进入翻滚状态的延迟函数,通过递增 index 实现从左往右的波浪律动。 |
| getExitDelay | (index: number) => number | (index) => index * 0.02 | 计算各字符恢复常态的延迟函数。 |
| variants | Variants | { rest: { y: "0%" }, hover: { y: "-50%" } } | 自定义字符轨道的 Motion 变体状态。 |
| transition | Transition | — | 合并注入到各个字符翻滚动画中的额外 Transition 过渡参数。 |
| className | string | — | 应用于外层 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时,组件将直接保持静态展示,不触发任何垂直滚动位移。