wui
组件

文本循环 Text Loop

在多段文本、关键词或行内节点之间自动平滑轮换切换的动效组件。

第三方依赖 · motion

基础用法

最简单的文本循环用法。传入一组子节点,组件将以设定的时间间隔在它们之间自动平滑过渡:

Loading…

安装与引入

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

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

属性类型默认值说明
childrenReact.ReactNode[]—按顺序依次轮换展示的 React 节点数组(可为纯文本、带样式的 span 或复杂行内块)。
intervalnumber2.5每个子项保持展示的时长(单位:秒)。
triggerbooleantrue控制自动轮换的播放/暂停状态。设为 false 时暂停轮换并保持当前项展示。
direction"up" | "down""up"默认动画的运动方向。up 为旧项从顶部离开、新项从底部进入;down 则相反。传入自定义 variants 时忽略。
animateWidthbooleanfalse子项长度不同时平滑过渡容器宽度,避免同一行后续文字跳动。开启后子项不换行。
variantsVariants—定义子项进入 (initial)、就绪 (animate) 与移出 (exit) 的 Motion 动画变体状态。
transitionTransition{ duration: 0.4, ease: [0.22, 1, 0.36, 1] }应用于子项切换(以及 animateWidth 宽度过渡)的 Motion 过渡动画参数。
mode"popLayout" | "sync" | "wait""popLayout"底层 AnimatePresence 的多元素编排排版模式。默认为 popLayout 确保无缝叠放。
classNamestring—应用于外层 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 组件呈现,切勿仅依赖轮播文字传递关键信息。