wui
组件

滚动文本动效 Scroll Text

依据滚动视口进度,逐字渐进高亮(Highlight)、遮罩位移显现(Reveal)或模糊聚焦(Blur)的段落文字动效组件。

第三方依赖 · motion

基础用法

随着向下滚动,文字由弱化色逐字点亮为前景色。中文按字切分,英文按单词切分:

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
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

属性类型默认值说明
childrenstring—需要受滚动进度控制的纯文本字符串内容。
mode"highlight" | "reveal" | "blur""highlight"显现风格。`highlight` 由弱化色点亮为前景色;`reveal` 带遮罩裁切自下而上登场;`blur` 由模糊、透明逐渐聚焦。
per"word" | "char" | "line""word"文本切分粒度。`word` 英文按单词、中日韩文字按单字切分(标点跟随前一字);`char` 逐字符切分且不会在英文单词内部换行;`line` 按换行符 `\n` 逐行切分。
asReact.ElementType"p"渲染出的底层 HTML 标签(如 `p`、`h1`、`h2`、`div` 等)。
range[number, number][0, 1]当前文本在滚动监听区间内所占的进度起始与终止百分比。
overlapnumber0.35相邻文字单元之间的动画重叠交织程度(数值越大越连贯,越小越分明)。
smoothbooleantrue通过轻量弹簧跟随滚动进度,让快速滚动时的文字过渡更柔和。
containerReact.RefObject<HTMLElement | null>—局部滚动容器的 ref 引用(不传时监听整个页面滚动)。
offsetUseScrollOptions["offset"]["start 0.85", "end 0.35"]Motion useScroll 监听的视口锚点区间。
segmentClassNamestring—应用于每个文本分段 span 的 CSS 类名。
classNamestring—应用于最外层文本容器的 CSS 类名。

使用场景与设计规范

ScrollText 适用于品牌宣言(Manifesto)、核心产品主张、引言与关键数字口号:

  • 节奏感与停留感:通过将整段大文字与滚动绑定,引导用户逐字品读核心价值,相比直接呈现能提高 3 倍以上的阅读停留率。
  • 排版连贯性:内置保留了空格与换行符逻辑,中文标点会与前一个字绑定,避免出现在行首。
  • 模糊模式的成本:blur 会逐段动画 filter,适合标题与短句,不建议用于长段落。

场景示例

逐行揭示(Reveal)

每一行带遮罩由下方浮现,适合年度回顾、关键数字等分行叙述:

Loading…

逐字聚焦(Blur)

标题逐字由模糊聚焦,配合 per="char" 与较大的 overlap 形成连贯的波浪感:

Loading…

无障碍与交互 Accessibility

  • 屏幕阅读器完整朗读:组件内部渲染一份视觉隐藏(sr-only)的完整文本,动画分段整体标记为 aria-hidden="true",读屏工具会一次性朗读整句,而不会被打碎成单个字词。
  • 动效减弱适配:在 prefers-reduced-motion: reduce 下,所有文字默认全部直接以最终高亮态显示,不产生任何透明度波动。