组件
文本轮转 Text Rotate
逐字符、单词或整行错峰轮换一组文本:旧文本向上移出、新文本自下方进入,容器宽度随新内容平滑过渡。
基础用法
传入 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"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 属性:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| texts | string[] | — | 依次轮换的文本数组。`split="lines"` 时可用 `\n` 表示换行。 |
| split | "characters" | "words" | "lines" | "characters" | 错峰动画的拆分粒度:逐字符、逐单词(按空格拆分)或逐行。 |
| direction | "up" | "down" | "up" | 运动方向。up 表示旧文本向上移出、新文本自下方进入;down 则相反。 |
| staggerFrom | "first" | "last" | "center" | "random" | number | "first" | 错峰的起点:首个元素、末尾、中心向两侧、随机顺序,或指定的元素下标。随机顺序为确定性计算,不会导致水合不一致。 |
| staggerDuration | number | 0.025 | 相邻两个元素之间的延迟(秒)。 |
| blur | boolean | false | 进出场时叠加模糊,让切换更柔和。 |
| auto | boolean | true | 是否按 `interval` 自动轮换。 |
| interval | number | 2.5 | 每段文本停留的时长(秒)。手动切换后会重新计时。 |
| loop | boolean | true | 到达最后一段后是否回到第一段。关闭后自动轮换会停在最后一段。 |
| paused | boolean | false | 暂停自动轮换,常用于悬停或聚焦时让用户读完当前内容。 |
| index | number | — | 受控模式下的当前下标,需配合 `onIndexChange` 使用。 |
| defaultIndex | number | 0 | 非受控模式下的初始下标。 |
| transition | Transition | { type: "spring", stiffness: 380, damping: 32, mass: 0.8 } | 每个动画元素的过渡参数,错峰延迟会自动叠加。 |
| sizeTransition | Transition | { type: "spring", stiffness: 520, damping: 38, mass: 0.7 } | 容器尺寸适配新文本时的过渡参数。characters 与 words 模式下过渡宽度;lines 模式允许自动换行,过渡高度。 |
| groupClassName | string | — | 应用于每个单词或每一行分组的类名。 |
| elementClassName | string | — | 应用于每个动画元素(字符、单词或行)的类名。 |
| ref | React.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 友好:首屏直接渲染第一段文本,宽度测量与随机错峰都在客户端挂载后进行,不会产生水合差异。