wui
组件

文本轮转 Text Rotate

逐字符、单词或整行错峰轮换一组文本:旧文本向上移出、新文本自下方进入,容器宽度随新内容平滑过渡。

第三方依赖 · motion

基础用法

传入 texts 数组即可自动轮换。每个字符按设定的错峰节奏向上移出,下一段文本的字符从下方依次进入;外层容器宽度会以弹簧动画过渡到新文本的宽度,因此放在标题中间也不会造成周围文字跳动:

Loading…

安装与引入

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

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

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

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

export type TextRotateSplit = "characters" | "words" | "lines"

export type TextRotateStaggerFrom =
  | "first"
  | "last"
  | "center"
  | "random"
  | number

/** Imperative methods exposed through `ref`. */
export interface TextRotateHandle {
  /** Rotate to the next text. Wraps to the first one when `loop` is on. */
  next: () => void
  /** Rotate to the previous text. Wraps to the last one when `loop` is on. */
  previous: () => void
  /** Rotate to a specific index. Out-of-range values are clamped. */
  jumpTo: (index: number) => void
  /** Rotate back to the first text. */
  reset: () => void
}

export interface TextRotateProps
  extends Omit<React.ComponentProps<"span">, "children" | "ref"> {
  /** Texts shown one after another. */
  texts: string[]
  /** Imperative handle with `next`, `previous`, `jumpTo` and `reset`. */
  ref?: React.Ref<TextRotateHandle>
  /** Controlled active index. Pair with `onIndexChange`. */
  index?: number
  /** Initial index when uncontrolled. @default 0 */
  defaultIndex?: number
  /** Called whenever the component wants to move to another index. */
  onIndexChange?: (index: number) => void
  /** Rotate automatically every `interval` seconds. @default true */
  auto?: boolean
  /** Seconds each text stays visible before rotating. @default 2.5 */
  interval?: number
  /** Wrap around after the last text. @default true */
  loop?: boolean
  /** Temporarily stop automatic rotation, e.g. while hovered. @default false */
  paused?: boolean
  /** Granularity of the staggered animation. @default "characters" */
  split?: TextRotateSplit
  /** Travel direction: `up` exits upward and enters from below. @default "up" */
  direction?: "up" | "down"
  /** Element the stagger starts from, or an explicit element index. @default "first" */
  staggerFrom?: TextRotateStaggerFrom
  /** Delay between two neighbouring elements, in seconds. @default 0.025 */
  staggerDuration?: number
  /** Blur elements while they enter and exit. @default false */
  blur?: boolean
  /** Transition applied to every animated element. */
  transition?: Transition
  /**
   * Transition used when the container resizes to fit the next text. Width is
   * animated for `characters` and `words`; height for `lines`, which may wrap.
   */
  sizeTransition?: Transition
  /** Class applied to every word or line group. */
  groupClassName?: string
  /** Class applied to every animated element (character, word or line). */
  elementClassName?: string
}

const defaultTransition: Transition = {
  type: "spring",
  stiffness: 380,
  damping: 32,
  mass: 0.8,
}

const defaultSizeTransition: Transition = {
  type: "spring",
  stiffness: 520,
  damping: 38,
  mass: 0.7,
}

const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" })

function splitText(text: string, split: TextRotateSplit): string[][] {
  if (split === "lines") return text.split("\n").map((line) => [line])
  if (split === "words") return text.split(" ").map((word) => [word])
  return text
    .split(" ")
    .map((word) => Array.from(graphemes.segment(word), (part) => part.segment))
}

function pseudoRandom(seed: number) {
  const value = Math.sin(seed * 12.9898 + 78.233) * 43758.5453
  return value - Math.floor(value)
}

function getStaggerDelay(
  position: number,
  total: number,
  from: TextRotateStaggerFrom,
  step: number,
  seed: number
) {
  if (from === "first") return position * step
  if (from === "last") return (total - 1 - position) * step
  if (from === "center") return Math.abs((total - 1) / 2 - position) * step
  if (from === "random")
    return Math.floor(pseudoRandom(position + seed * 97) * total) * step
  return Math.abs(from - position) * step
}

// Variants live in context so an exiting item picks up a direction change made
// in the same render that removed it (AnimatePresence keeps its stale props).
const TextRotateVariantsContext = React.createContext<Variants>({})

interface TextRotateItemProps {
  ref?: React.Ref<HTMLSpanElement>
  text: string
  seed: number
  split: TextRotateSplit
  staggerFrom: TextRotateStaggerFrom
  staggerDuration: number
  groupClassName?: string
  elementClassName?: string
  onResize: (size: ResizeObserverSize) => void
}

function TextRotateItem({
  ref,
  text,
  seed,
  split,
  staggerFrom,
  staggerDuration,
  groupClassName,
  elementClassName,
  onResize,
}: TextRotateItemProps) {
  const variants = React.useContext(TextRotateVariantsContext)
  const localRef = React.useRef<HTMLSpanElement>(null)
  const isPresent = useIsPresent()
  const groups = React.useMemo(() => splitText(text, split), [text, split])
  const total = groups.reduce((count, group) => count + group.length, 0)

  const setRefs = React.useCallback(
    (node: HTMLSpanElement | null) => {
      localRef.current = node
      if (typeof ref === "function") ref(node)
      else if (ref) ref.current = node
    },
    [ref]
  )

  React.useEffect(() => {
    const node = localRef.current
    if (!isPresent || !node) return
    const observer = new ResizeObserver(([entry]) => {
      onResize(entry.borderBoxSize[0])
    })
    observer.observe(node)
    return () => observer.disconnect()
  }, [isPresent, onResize])

  let position = 0

  return (
    <span
      ref={setRefs}
      data-slot="text-rotate-item"
      className={cn(
        split === "lines"
          ? "flex w-full flex-col"
          : "inline-flex shrink-0 whitespace-pre"
      )}
    >
      {groups.map((group, groupIndex) => (
        <React.Fragment key={groupIndex}>
          <span
            data-slot="text-rotate-group"
            className={cn(
              split === "lines" ? "flex" : "inline-flex",
              groupClassName
            )}
          >
            {group.map((element, elementIndex) => {
              const delay = getStaggerDelay(
                position++,
                total,
                staggerFrom,
                staggerDuration,
                seed
              )
              return (
                <motion.span
                  key={elementIndex}
                  data-slot="text-rotate-element"
                  className={cn("inline-block", elementClassName)}
                  custom={delay}
                  variants={variants}
                  initial="initial"
                  animate="animate"
                  exit="exit"
                >
                  {element}
                </motion.span>
              )
            })}
          </span>
          {split !== "lines" && groupIndex < groups.length - 1 ? (
            <span className="whitespace-pre"> </span>
          ) : null}
        </React.Fragment>
      ))}
    </span>
  )
}

/**
 * Rotates through a list of texts. Outgoing characters, words or lines leave
 * in one direction with a stagger while the next text enters from the other
 * side, and the container eases to the size of the new text.
 */
function TextRotate({
  texts,
  ref,
  index,
  defaultIndex = 0,
  onIndexChange,
  auto = true,
  interval = 2.5,
  loop = true,
  paused = false,
  split = "characters",
  direction = "up",
  staggerFrom = "first",
  staggerDuration = 0.025,
  blur = false,
  transition = defaultTransition,
  sizeTransition = defaultSizeTransition,
  groupClassName,
  elementClassName,
  className,
  ...props
}: TextRotateProps) {
  const reduceMotion = useReducedMotion()
  const [internalIndex, setInternalIndex] = React.useState(defaultIndex)
  const [size, setSize] = React.useState<ResizeObserverSize | null>(null)
  const isControlled = index !== undefined
  const lastIndex = texts.length - 1
  const currentIndex = Math.min(isControlled ? index : internalIndex, lastIndex)

  // Keep the latest callback without restarting the auto-rotate timer when a
  // parent passes an inline function.
  const onIndexChangeRef = React.useRef(onIndexChange)
  React.useEffect(() => {
    onIndexChangeRef.current = onIndexChange
  })

  const setIndex = React.useCallback(
    (nextIndex: number) => {
      if (nextIndex === currentIndex) return
      if (!isControlled) setInternalIndex(nextIndex)
      onIndexChangeRef.current?.(nextIndex)
    },
    [currentIndex, isControlled]
  )

  const next = React.useCallback(() => {
    if (currentIndex < lastIndex) setIndex(currentIndex + 1)
    else if (loop) setIndex(0)
  }, [currentIndex, lastIndex, loop, setIndex])

  const previous = React.useCallback(() => {
    if (currentIndex > 0) setIndex(currentIndex - 1)
    else if (loop) setIndex(lastIndex)
  }, [currentIndex, lastIndex, loop, setIndex])

  React.useImperativeHandle(
    ref,
    () => ({
      next,
      previous,
      jumpTo: (target) => setIndex(Math.max(0, Math.min(target, lastIndex))),
      reset: () => setIndex(0),
    }),
    [lastIndex, next, previous, setIndex]
  )

  React.useEffect(() => {
    if (!auto || paused || texts.length < 2) return
    if (!loop && currentIndex === lastIndex) return
    const timer = window.setTimeout(next, interval * 1000)
    return () => window.clearTimeout(timer)
  }, [auto, currentIndex, interval, lastIndex, loop, next, paused, texts.length])

  const variants = React.useMemo<Variants>(() => {
    const enterY = direction === "up" ? "100%" : "-100%"
    const exitY = direction === "up" ? "-120%" : "120%"
    const hidden = (y: string) =>
      blur ? { y, opacity: 0, filter: "blur(6px)" } : { y, opacity: 0 }
    const shown = blur
      ? { y: "0%", opacity: 1, filter: "blur(0px)" }
      : { y: "0%", opacity: 1 }
    const timed = (delay: number) =>
      reduceMotion ? { duration: 0 } : { ...transition, delay }

    return {
      initial: hidden(enterY),
      animate: (delay: number) => ({ ...shown, transition: timed(delay) }),
      exit: (delay: number) => ({ ...hidden(exitY), transition: timed(delay) }),
    }
  }, [blur, direction, reduceMotion, transition])

  const handleResize = React.useCallback(
    (next: ResizeObserverSize) => setSize(next),
    []
  )
  const lines = split === "lines"
  const target =
    size === null
      ? undefined
      : lines
        ? { height: size.blockSize }
        : { width: size.inlineSize }

  return (
    <span
      data-slot="text-rotate"
      className={cn("inline-flex", className)}
      {...props}
    >
      <span className="sr-only">{texts[currentIndex]}</span>
      <motion.span
        aria-hidden="true"
        data-slot="text-rotate-viewport"
        className={cn(
          "relative -my-[0.15em] overflow-hidden py-[0.15em]",
          lines ? "box-content flex min-w-0 flex-1" : "inline-flex"
        )}
        initial={false}
        animate={target}
        transition={reduceMotion ? { duration: 0 } : sizeTransition}
      >
        <TextRotateVariantsContext.Provider value={variants}>
          <AnimatePresence initial={false} mode="popLayout">
            <TextRotateItem
              key={currentIndex}
              text={texts[currentIndex]}
              seed={currentIndex}
              split={split}
              staggerFrom={staggerFrom}
              staggerDuration={reduceMotion ? 0 : staggerDuration}
              groupClassName={groupClassName}
              elementClassName={elementClassName}
              onResize={handleResize}
            />
          </AnimatePresence>
        </TextRotateVariantsContext.Provider>
      </motion.span>
    </span>
  )
}

export { TextRotate }

属性 Props

TextRotate 支持以下配置属性,并继承原生 <span> 的其余 HTML 属性:

属性类型默认值说明
textsstring[]—依次轮换的文本数组。`split="lines"` 时可用 `\n` 表示换行。
split"characters" | "words" | "lines""characters"错峰动画的拆分粒度:逐字符、逐单词(按空格拆分)或逐行。
direction"up" | "down""up"运动方向。up 表示旧文本向上移出、新文本自下方进入;down 则相反。
staggerFrom"first" | "last" | "center" | "random" | number"first"错峰的起点:首个元素、末尾、中心向两侧、随机顺序,或指定的元素下标。随机顺序为确定性计算,不会导致水合不一致。
staggerDurationnumber0.025相邻两个元素之间的延迟(秒)。
blurbooleanfalse进出场时叠加模糊,让切换更柔和。
autobooleantrue是否按 `interval` 自动轮换。
intervalnumber2.5每段文本停留的时长(秒)。手动切换后会重新计时。
loopbooleantrue到达最后一段后是否回到第一段。关闭后自动轮换会停在最后一段。
pausedbooleanfalse暂停自动轮换,常用于悬停或聚焦时让用户读完当前内容。
indexnumber—受控模式下的当前下标,需配合 `onIndexChange` 使用。
defaultIndexnumber0非受控模式下的初始下标。
transitionTransition{ type: "spring", stiffness: 380, damping: 32, mass: 0.8 }每个动画元素的过渡参数,错峰延迟会自动叠加。
sizeTransitionTransition{ type: "spring", stiffness: 520, damping: 38, mass: 0.7 }容器尺寸适配新文本时的过渡参数。characters 与 words 模式下过渡宽度;lines 模式允许自动换行,过渡高度。
groupClassNamestring—应用于每个单词或每一行分组的类名。
elementClassNamestring—应用于每个动画元素(字符、单词或行)的类名。
refReact.Ref<TextRotateHandle>—获取命令式方法:`next()`、`previous()`、`jumpTo(index)` 与 `reset()`。

事件 Events

属性类型默认值说明
onIndexChange(index: number) => void—自动轮换或调用命令式方法切换下标时触发,参数为新的下标。

使用场景与设计规范

TextRotate 适合营销页主标题中的关键词轮换、产品能力或受众的并列展示,以及紧凑的状态文案。与 文本循环 Text Loop 整块替换不同,它把文本拆成字符、单词或行逐个运动,并自动适配宽度。

  • 同一语法位置:轮换的文本应能填入同一句话的同一位置,保证任意时刻整句通顺,例如「为 设计师 / 开发者 打造」。
  • 拆分粒度与文本长度匹配:短词用 characters;较长的英文短句用 words,避免错峰总时长过长;整句提示或中文长句用 lines,此时文本可随容器宽度换行,组件改为过渡高度。
  • 克制的错峰:字符级 staggerDuration 建议在 0.02–0.04 秒之间,总时长超过 0.6 秒会让人觉得拖沓。
  • 给阅读留时间:信息量较大的文案建议把 interval 调到 4 秒以上,并在悬停时通过 paused 暂停。

场景示例

字符级错峰与方向

切换错峰起点、运动方向与模糊,观察同一组英文单词的不同节奏:

Loading…

按单词轮换状态文案

split="words" 按空格拆分,适合长度差异较大的日志或状态短句:

Loading…

命令式控制与悬停暂停

通过 ref 调用 previous() / next(),结合 direction 让“上一条”向下滚动、“下一条”向上滚动;悬停时 paused 暂停自动轮换,onIndexChange 同步计数:

Loading…
const rotateRef = React.useRef<TextRotateHandle>(null)

<TextRotate ref={rotateRef} texts={tips} split="lines" paused={hovered} />
<Button onClick={() => rotateRef.current?.next()}>下一条</Button>

无障碍与交互 Accessibility

  • 完整文本朗读:组件内置一个 sr-only 节点输出当前完整文本,视觉上被拆分的字符节点均设置了 aria-hidden,读屏软件不会逐字朗读。
  • 不打断用户:当前文本没有使用 aria-live,自动轮换不会持续打断读屏播报。若轮换内容本身是需要播报的状态,请在外层自行添加 aria-live="polite"。
  • 减少动态效果:开启 prefers-reduced-motion 时,字符位移、错峰与宽度动画全部即时完成,仅保留内容切换。
  • SSR 友好:首屏直接渲染第一段文本,宽度测量与随机错峰都在客户端挂载后进行,不会产生水合差异。