组件
文本循环 Text Loop
在多段文本、关键词或行内节点之间自动平滑轮换切换的动效组件。
基础用法
最简单的文本循环用法。传入一组子节点,组件将以设定的时间间隔在它们之间自动平滑过渡:
Loading…
安装与引入
通过 CLI 自动添加组件,或手动复制源码至项目中:
pnpm dlx @wui-design/cli@latest add @wui/text-loop安装基础依赖与 Motion 动效库
pnpm add motion clsx tailwind-merge复制组件源码到
components/ui/text-loop.tsx"use client"
import * as React from "react"
import {
AnimatePresence,
motion,
useReducedMotion,
type AnimatePresenceProps,
type Transition,
type Variants,
} from "motion/react"
import { cn } from "@/lib/utils"
function directionalVariants(direction: "up" | "down"): Variants {
const offset = direction === "up" ? "0.6em" : "-0.6em"
const exitOffset = direction === "up" ? "-0.6em" : "0.6em"
return {
initial: { y: offset, opacity: 0, filter: "blur(4px)" },
animate: { y: "0em", opacity: 1, filter: "blur(0px)" },
exit: { y: exitOffset, opacity: 0, filter: "blur(4px)" },
}
}
const variantsByDirection = {
up: directionalVariants("up"),
down: directionalVariants("down"),
}
export interface TextLoopProps extends React.ComponentProps<"span"> {
/** Content items displayed one after another. */
children: React.ReactNode[]
/** Seconds between item changes. @default 2.5 */
interval?: number
/**
* Travel direction of the default animation. `"up"` exits through the top
* and enters from the bottom; `"down"` does the reverse. Ignored when
* custom `variants` are passed. @default "up"
*/
direction?: "up" | "down"
/** Motion transition for each item. */
transition?: Transition
/** Initial, animate and exit states. */
variants?: Variants
/** Animate the container width as items of different lengths swap. @default false */
animateWidth?: boolean
/** Called after the active index changes. */
onIndexChange?: (index: number) => void
/** Start or pause automatic changes. @default true */
trigger?: boolean
/** AnimatePresence sequencing mode. @default "popLayout" */
mode?: AnimatePresenceProps["mode"]
}
/** Cycles through an array of text or inline content. */
function TextLoop({
children,
className,
interval = 2.5,
direction = "up",
transition = { duration: 0.4, ease: [0.22, 1, 0.36, 1] },
variants,
animateWidth = false,
onIndexChange,
trigger = true,
mode = "popLayout",
...props
}: TextLoopProps) {
const [index, setIndex] = React.useState(0)
const [width, setWidth] = React.useState<number>()
const itemRef = React.useRef<HTMLSpanElement>(null)
const onIndexChangeRef = React.useRef(onIndexChange)
const firstRender = React.useRef(true)
const reduceMotion = useReducedMotion()
const count = children.length
const activeIndex = index < count ? index : 0
React.useEffect(() => {
onIndexChangeRef.current = onIndexChange
})
React.useEffect(() => {
if (!trigger || count < 2) return
const timer = window.setInterval(() => {
setIndex((current) => (current + 1) % count)
}, interval * 1000)
return () => window.clearInterval(timer)
}, [count, interval, trigger])
React.useEffect(() => {
if (firstRender.current) {
firstRender.current = false
return
}
onIndexChangeRef.current?.(activeIndex)
}, [activeIndex])
React.useLayoutEffect(() => {
if (!animateWidth || !itemRef.current) return
setWidth(itemRef.current.offsetWidth)
}, [activeIndex, animateWidth])
return (
<motion.span
data-slot="text-loop"
className={cn(
"relative inline-grid overflow-hidden align-bottom",
className
)}
initial={false}
animate={animateWidth && width !== undefined ? { width } : undefined}
transition={reduceMotion ? { duration: 0 } : transition}
{...(props as React.ComponentProps<typeof motion.span>)}
>
<AnimatePresence initial={false} mode={mode}>
<motion.span
ref={itemRef}
data-slot="text-loop-item"
className={cn(
"col-start-1 row-start-1 inline-block",
animateWidth && "justify-self-start whitespace-nowrap"
)}
key={activeIndex}
variants={variants ?? variantsByDirection[direction]}
initial="initial"
animate="animate"
exit="exit"
transition={reduceMotion ? { duration: 0 } : transition}
>
{children[activeIndex]}
</motion.span>
</AnimatePresence>
</motion.span>
)
}
export { TextLoop }
属性 Props
TextLoop 支持以下配置属性,并继承原生 <span> 的全部 HTML 属性:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| children | React.ReactNode[] | — | 按顺序依次轮换展示的 React 节点数组(可为纯文本、带样式的 span 或复杂行内块)。 |
| interval | number | 2.5 | 每个子项保持展示的时长(单位:秒)。 |
| trigger | boolean | true | 控制自动轮换的播放/暂停状态。设为 false 时暂停轮换并保持当前项展示。 |
| direction | "up" | "down" | "up" | 默认动画的运动方向。up 为旧项从顶部离开、新项从底部进入;down 则相反。传入自定义 variants 时忽略。 |
| animateWidth | boolean | false | 子项长度不同时平滑过渡容器宽度,避免同一行后续文字跳动。开启后子项不换行。 |
| variants | Variants | — | 定义子项进入 (initial)、就绪 (animate) 与移出 (exit) 的 Motion 动画变体状态。 |
| transition | Transition | { duration: 0.4, ease: [0.22, 1, 0.36, 1] } | 应用于子项切换(以及 animateWidth 宽度过渡)的 Motion 过渡动画参数。 |
| mode | "popLayout" | "sync" | "wait" | "popLayout" | 底层 AnimatePresence 的多元素编排排版模式。默认为 popLayout 确保无缝叠放。 |
| className | string | — | 应用于外层 span 容器的额外 CSS 类名。 |
事件 Events
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| onIndexChange | (index: number) => void | — | 当活动子项索引发生切换时触发的回调函数,参数为最新的索引序号(从 0 开始)。 |
使用场景与设计规范
TextLoop 适用于在有限的水平空间内展示多样化或动态更新的内容,常见于产品主标语的关键词轮换、动态公告胶囊栏、客户评价轮播和系统状态展示。
- 尺寸相近原则:轮换的各个子项应尽量保持相近的字符长度与物理高度。长度差异无法避免时,开启
animateWidth让容器宽度平滑过渡。 - 语义一致性:同一行轮换的内容必须属于同一个语法范畴(例如皆为主语修饰词、技术栈名称或功能特性),确保句子在任何时刻都能完整通顺地表达含义。
- 可控交互与悬停暂停:对于包含较多信息量的公告栏或评价,强烈建议搭配悬停暂停(
onMouseEnter时设置trigger={false}),让用户有充足的时间阅读并点击感兴趣的条目。
场景示例
运动方向
通过 direction 切换默认动画方向:up 从底部进入、从顶部离开,down 反之,适合表达状态推进或回溯:
Loading…
业务动态与公告胶囊
将徽章与文字的复合节点放入 TextLoop,并开启 animateWidth,胶囊宽度会随文案长度平滑伸缩:
Loading…
自定义过渡方向与 3D 翻转
通过传入自定义 variants,轻松实现横向推入、3D 空间翻转或缩放淡入等多种运动质感:
Loading…
const flipVariants = {
initial: { rotateX: 90, opacity: 0 },
animate: { rotateX: 0, opacity: 1 },
exit: { rotateX: -90, opacity: 0 },
}
export function FlipLoop() {
return (
<span className="[perspective:600px]">
部署到
<TextLoop
variants={flipVariants}
transition={{ type: "spring", stiffness: 320, damping: 24 }}
>
{[
<span key="north">华北</span>,
<span key="east">华东</span>,
<span key="south">华南</span>,
]}
</TextLoop>
</span>
)
}悬停暂停与步骤指示器
结合 trigger、onIndexChange 与外部状态,实现悬停自动暂停、手动播放/暂停控制以及同步步骤小圆点:
Loading…
无障碍与交互 Accessibility
- 减少动态效果降级:组件内置适配
prefers-reduced-motion。当系统开启减少动态效果时,过渡时长将自动归零,新旧内容将以即时切换的形式呈现,避免产生眩晕感。 - 布局防抖(Layout Stability):外层容器采用
inline-grid与col-start-1 row-start-1的网格层叠布局,配合AnimatePresence mode="popLayout",使得退出的旧项与进入的新项处于同一几何网格中,杜绝了由于绝对定位脱离文档流引发的父容器高度塌陷。 - 核心关键状态警示:对于关系到资金、数据安全或系统致命故障的核心警报,请直接使用静态明确的
Alert组件呈现,切勿仅依赖轮播文字传递关键信息。