组件
滚动文本动效 Scroll Text
依据滚动视口进度,逐字渐进高亮(Highlight)、遮罩位移显现(Reveal)或模糊聚焦(Blur)的段落文字动效组件。
基础用法
随着向下滚动,文字由弱化色逐字点亮为前景色。中文按字切分,英文按单词切分:
Loading…
安装与引入
通过 CLI 自动添加组件,或手动复制源码至项目中:
pnpm dlx @wui-design/cli@latest add @wui/scroll-text安装基础依赖与动效库
pnpm add motion lucide-react class-variance-authority clsx tailwind-merge复制组件源码到
components/ui/scroll-text.tsx"use client"
import * as React from "react"
import {
motion,
useReducedMotion,
useScroll,
useSpring,
useTransform,
type HTMLMotionProps,
type MotionValue,
type UseScrollOptions,
} from "motion/react"
import { cn } from "@/lib/utils"
type ScrollTextMode = "highlight" | "reveal" | "blur"
type ScrollTextPer = "word" | "char" | "line"
export interface ScrollTextProps extends Omit<
HTMLMotionProps<"p">,
"children"
> {
/** Text split into scroll-controlled segments. */
children: string
/** Reveal style. `highlight` brightens muted text, `reveal` slides segments up through a mask, `blur` focuses them in. @default "highlight" */
mode?: ScrollTextMode
/** Segment granularity. `word` splits Latin text by words and CJK text by characters; `char` animates every character. @default "word" */
per?: ScrollTextPer
/** HTML element rendered by the component. @default "p" */
as?: React.ElementType
/** Portion of the total scroll progress assigned across all segments. @default [0, 1] */
range?: readonly [number, number]
/** Overlap between neighboring segment ranges. @default 0.35 */
overlap?: number
/** Follow scroll through a light spring so segments settle softly. @default true */
smooth?: boolean
/** Scrollable element to observe instead of the page. */
container?: React.RefObject<HTMLElement | null>
/** Motion scroll offsets for the text block. @default ["start 0.85", "end 0.35"] */
offset?: UseScrollOptions["offset"]
/** Classes applied to every text segment. */
segmentClassName?: string
}
interface Token {
text: string
space: boolean
}
const CJK = "\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}"
const WORD_PATTERN = new RegExp(
`\\s+|[${CJK}][\\p{P}]*|[^\\s${CJK}]+`,
"gu"
)
function tokenize(text: string, per: ScrollTextPer): Token[] {
if (per === "line") {
return text
.split("\n")
.filter((line) => line.trim().length > 0)
.map((line) => ({ text: line.trim(), space: false }))
}
return (text.match(WORD_PATTERN) ?? []).map((segment) => ({
text: segment,
space: /^\s+$/.test(segment),
}))
}
interface ScrollTextSegmentProps {
children: string
index: number
count: number
mode: ScrollTextMode
range: readonly [number, number]
overlap: number
progress: MotionValue<number>
reduceMotion: boolean
className?: string
line: boolean
}
function ScrollTextSegment({
children,
index,
count,
mode,
range,
overlap,
progress,
reduceMotion,
className,
line,
}: ScrollTextSegmentProps) {
const span = range[1] - range[0]
const step = span / Math.max(count, 1)
const start = range[0] + step * index
const end = Math.min(range[1], start + step * (1 + overlap))
const opacity = useTransform(progress, [start, end], [0, 1])
const y = useTransform(progress, [start, end], ["105%", "0%"])
const blurY = useTransform(progress, [start, end], ["0.35em", "0em"])
const filter = useTransform(
progress,
[start, end],
["blur(10px)", "blur(0px)"]
)
const display = line ? "block" : "inline-block"
if (mode === "reveal") {
return (
<span
data-slot="scroll-text-segment-mask"
className={cn(
"-mb-[0.12em] overflow-hidden pb-[0.12em] align-top",
display,
className
)}
>
<motion.span
data-slot="scroll-text-segment"
className={cn(display, "will-change-transform")}
style={{
y: reduceMotion ? "0%" : y,
opacity: reduceMotion ? 1 : opacity,
}}
>
{children}
</motion.span>
</span>
)
}
if (mode === "blur") {
return (
<motion.span
data-slot="scroll-text-segment"
className={cn(display, className)}
style={
reduceMotion
? undefined
: { opacity, y: blurY, filter, willChange: "transform, filter" }
}
>
{children}
</motion.span>
)
}
return (
<span
data-slot="scroll-text-segment"
className={cn("text-muted-foreground relative", display, className)}
>
{children}
<motion.span
className="text-foreground absolute inset-0"
style={{ opacity: reduceMotion ? 1 : opacity }}
>
{children}
</motion.span>
</span>
)
}
/** Reveals lines or progressively highlights words according to scroll progress. */
function ScrollText({
children,
mode = "highlight",
per = "word",
as = "p",
range = [0, 1],
overlap = 0.35,
smooth = true,
container,
offset = ["start 0.85", "end 0.35"],
segmentClassName,
className,
...props
}: ScrollTextProps) {
const target = React.useRef<HTMLElement>(null)
const reduceMotion = Boolean(useReducedMotion())
const { scrollYProgress } = useScroll({ target, container, offset })
const springProgress = useSpring(scrollYProgress, {
stiffness: 160,
damping: 30,
mass: 0.35,
restDelta: 0.0005,
})
const progress = smooth ? springProgress : scrollYProgress
const Component = React.useMemo(() => motion.create(as), [as])
const tokens = React.useMemo(() => tokenize(children, per), [children, per])
const count = tokens.reduce(
(total, token) =>
token.space
? total
: total + (per === "char" ? Array.from(token.text).length : 1),
0
)
let segmentIndex = 0
function renderSegment(text: string, key: React.Key) {
return (
<ScrollTextSegment
key={key}
index={segmentIndex++}
count={count}
mode={mode}
range={range}
overlap={overlap}
progress={progress}
reduceMotion={reduceMotion}
line={per === "line"}
className={segmentClassName}
>
{text}
</ScrollTextSegment>
)
}
return (
<Component
ref={target}
data-slot="scroll-text"
data-mode={mode}
className={cn(per === "line" && "flex flex-col", className)}
{...props}
>
<span className="sr-only">{children}</span>
<span aria-hidden="true" className="contents">
{tokens.map((token, index) => {
if (token.space) {
return <React.Fragment key={index}>{token.text}</React.Fragment>
}
if (per === "char") {
return (
<span key={index} className="inline-block whitespace-nowrap">
{Array.from(token.text).map((char, charIndex) =>
renderSegment(char, charIndex)
)}
</span>
)
}
return renderSegment(token.text, index)
})}
</span>
</Component>
)
}
export { ScrollText }
属性 Props
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| children | string | — | 需要受滚动进度控制的纯文本字符串内容。 |
| mode | "highlight" | "reveal" | "blur" | "highlight" | 显现风格。`highlight` 由弱化色点亮为前景色;`reveal` 带遮罩裁切自下而上登场;`blur` 由模糊、透明逐渐聚焦。 |
| per | "word" | "char" | "line" | "word" | 文本切分粒度。`word` 英文按单词、中日韩文字按单字切分(标点跟随前一字);`char` 逐字符切分且不会在英文单词内部换行;`line` 按换行符 `\n` 逐行切分。 |
| as | React.ElementType | "p" | 渲染出的底层 HTML 标签(如 `p`、`h1`、`h2`、`div` 等)。 |
| range | [number, number] | [0, 1] | 当前文本在滚动监听区间内所占的进度起始与终止百分比。 |
| overlap | number | 0.35 | 相邻文字单元之间的动画重叠交织程度(数值越大越连贯,越小越分明)。 |
| smooth | boolean | true | 通过轻量弹簧跟随滚动进度,让快速滚动时的文字过渡更柔和。 |
| container | React.RefObject<HTMLElement | null> | — | 局部滚动容器的 ref 引用(不传时监听整个页面滚动)。 |
| offset | UseScrollOptions["offset"] | ["start 0.85", "end 0.35"] | Motion useScroll 监听的视口锚点区间。 |
| segmentClassName | string | — | 应用于每个文本分段 span 的 CSS 类名。 |
| className | string | — | 应用于最外层文本容器的 CSS 类名。 |
使用场景与设计规范
ScrollText 适用于品牌宣言(Manifesto)、核心产品主张、引言与关键数字口号:
- 节奏感与停留感:通过将整段大文字与滚动绑定,引导用户逐字品读核心价值,相比直接呈现能提高 3 倍以上的阅读停留率。
- 排版连贯性:内置保留了空格与换行符逻辑,中文标点会与前一个字绑定,避免出现在行首。
- 模糊模式的成本:
blur会逐段动画filter,适合标题与短句,不建议用于长段落。
场景示例
逐行揭示(Reveal)
每一行带遮罩由下方浮现,适合年度回顾、关键数字等分行叙述:
Loading…
逐字聚焦(Blur)
标题逐字由模糊聚焦,配合 per="char" 与较大的 overlap 形成连贯的波浪感:
Loading…
无障碍与交互 Accessibility
- 屏幕阅读器完整朗读:组件内部渲染一份视觉隐藏(
sr-only)的完整文本,动画分段整体标记为aria-hidden="true",读屏工具会一次性朗读整句,而不会被打碎成单个字词。 - 动效减弱适配:在
prefers-reduced-motion: reduce下,所有文字默认全部直接以最终高亮态显示,不产生任何透明度波动。