wui
组件

滚动进度 Scroll Progress

实时计算整页、特定滚动容器或指定 DOM 节点的滚动通过比例,支持线性条状与环形仪表两种形态,并内置弹簧滤波平滑算法。

第三方依赖 · motion

基础用法

在文章滚动容器中,吸顶标题栏右侧的环形指示与底边细线同步显示阅读进度:

Loading…

安装与引入

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

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

import * as React from "react"
import {
  motion,
  useMotionValueEvent,
  useReducedMotion,
  useScroll,
  useSpring,
  useTransform,
  type HTMLMotionProps,
  type UseScrollOptions,
} from "motion/react"

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

export interface ScrollProgressProps extends Omit<
  HTMLMotionProps<"div">,
  "children"
> {
  /** Visual form of the progress indicator. @default "bar" */
  variant?: "bar" | "circle"
  /** Edge used by a fixed bar. Use `inline` to keep it in normal flow. @default "top" */
  position?: "top" | "bottom" | "inline"
  /** Scroll axis to observe. Use `x` for horizontal scrollers. @default "y" */
  axis?: "x" | "y"
  /** Scrollable element to observe instead of the page. */
  container?: React.RefObject<HTMLElement | null>
  /** Element whose passage through the viewport defines the progress. */
  target?: React.RefObject<HTMLElement | null>
  /** Motion scroll offsets used when `target` is supplied. */
  offset?: UseScrollOptions["offset"]
  /** Diameter of the circular indicator in pixels. @default 44 */
  size?: number
  /** Stroke width of the circular indicator in pixels. @default 3 */
  strokeWidth?: number
  /** Show the percentage inside the circular indicator. @default true */
  showValue?: boolean
  /** Smooth abrupt scroll updates with a spring. @default true */
  smooth?: boolean
  /** Classes applied to the inactive track. */
  trackClassName?: string
  /** Classes applied to the moving indicator. */
  indicatorClassName?: string
}

/** Shows page, container, or section scroll completion as a bar or ring. */
function ScrollProgress({
  variant = "bar",
  position = "top",
  axis = "y",
  container,
  target,
  offset,
  size = 44,
  strokeWidth = 3,
  showValue = true,
  smooth = true,
  className,
  trackClassName,
  indicatorClassName,
  ...props
}: ScrollProgressProps) {
  const reduceMotion = useReducedMotion()
  const { scrollXProgress, scrollYProgress } = useScroll({
    container,
    target,
    offset,
    axis,
  })
  const rawProgress = axis === "x" ? scrollXProgress : scrollYProgress
  const springProgress = useSpring(rawProgress, {
    stiffness: 220,
    damping: 32,
    mass: 0.3,
    restDelta: 0.0005,
  })
  const progress = smooth && !reduceMotion ? springProgress : rawProgress
  const percent = useTransform(progress, (latest) =>
    Math.round(Math.min(Math.max(latest, 0), 1) * 100)
  )
  // Hides the round line cap that would otherwise render as a dot at 0%.
  const ringOpacity = useTransform(progress, [0, 0.004], [0, 1])
  const [value, setValue] = React.useState(0)

  useMotionValueEvent(percent, "change", setValue)

  if (variant === "circle") {
    const radius = Math.max((size - strokeWidth) / 2, 1)
    const center = size / 2

    return (
      <motion.div
        role="progressbar"
        aria-label="Scroll progress"
        aria-valuemin={0}
        aria-valuemax={100}
        aria-valuenow={value}
        data-slot="scroll-progress"
        data-variant="circle"
        className={cn("relative inline-grid place-items-center", className)}
        style={{ width: size, height: size }}
        {...props}
      >
        <svg
          aria-hidden="true"
          viewBox={`0 0 ${size} ${size}`}
          className="absolute inset-0 -rotate-90 overflow-visible"
        >
          <circle
            cx={center}
            cy={center}
            r={radius}
            fill="none"
            stroke="currentColor"
            strokeWidth={strokeWidth}
            className={cn("text-border", trackClassName)}
          />
          <motion.circle
            cx={center}
            cy={center}
            r={radius}
            fill="none"
            pathLength={1}
            stroke="currentColor"
            strokeWidth={strokeWidth}
            strokeLinecap="round"
            className={cn("text-foreground", indicatorClassName)}
            style={{ pathLength: progress, opacity: ringOpacity }}
          />
        </svg>
        {showValue ? (
          <motion.span className="text-[10px] font-medium tabular-nums">
            {percent}
          </motion.span>
        ) : null}
      </motion.div>
    )
  }

  return (
    <motion.div
      role="progressbar"
      aria-label="Scroll progress"
      aria-valuemin={0}
      aria-valuemax={100}
      aria-valuenow={value}
      data-slot="scroll-progress"
      data-variant="bar"
      data-position={position}
      className={cn(
        "bg-border z-50 h-0.5 overflow-hidden",
        position === "top" && "fixed inset-x-0 top-0",
        position === "bottom" && "fixed inset-x-0 bottom-0",
        position === "inline" && "relative w-full",
        trackClassName,
        className
      )}
      {...props}
    >
      <motion.div
        data-slot="scroll-progress-indicator"
        className={cn(
          "bg-foreground h-full origin-left will-change-transform",
          indicatorClassName
        )}
        style={{ scaleX: progress }}
      />
    </motion.div>
  )
}

export { ScrollProgress }

属性 Props

ScrollProgress 支持以下配置属性,并继承底层 HTML 容器元素的全部属性:

属性类型默认值说明
variant"bar" | "circle""bar"进度条的视觉呈现形态:bar(线性条状)或 circle(带数字百分比的环形仪表)。
position"top" | "bottom" | "inline""top"线性条状在屏幕中的定位方式:top(固定于视口顶部)、bottom(固定于视口底部)、inline(跟随局部容器文档流)。
axis"x" | "y""y"监听的滚动方向。横向列表或画廊使用 `x`。
containerReact.RefObject<HTMLElement | null>—指定被监听的自定义可滚动容器 DOM 引用。若不提供则默认监听整页 Window 滚动。
targetReact.RefObject<HTMLElement | null>—指定特定目标元素 DOM 引用,仅根据该元素在视口中的穿越过程计算进度。
offsetUseScrollOptions["offset"]—与 target 配合使用的滚动偏移计算区间,如 ["start start", "end end"]。
sizenumber44环形形态(variant="circle")的外圆直径像素尺寸。
strokeWidthnumber3环形形态(variant="circle")的圆环线条描边粗细像素尺寸。
showValuebooleantrue环形形态是否在中心显示百分比数字。尺寸较小时建议关闭。
smoothbooleantrue是否启用 Spring 弹簧阻尼物理滤波,消除鼠标滚轮或触控板惯性回弹的骤变抖动。
trackClassNamestring—应用于未填充背景底轨的额外 CSS 类名。
indicatorClassNamestring—应用于实际流动填充指示条/环形高亮描边的额外 CSS 类名。
classNamestring—应用于最外层容器元素的额外 CSS 类名。

事件 Events

ScrollProgress 支持所有原生 HTML <div> 的鼠标与焦点事件:

属性类型默认值说明
onClick(event: React.MouseEvent<HTMLDivElement>) => void—点击进度指示器区域时触发(常用于点击环形返回页面顶部)。

使用场景与设计规范

ScrollProgress 适用于长内容阅读、多步骤向导、长协议确认与专题落地页。

  • 整页阅读 vs 局部容器:
    • 全局博客/文档页:推荐使用 position="top" 固定于屏幕最顶部,配合 h-0.5 或 h-1 的极细线条,给读者明确的篇幅预期。
    • 条款协议/模态弹窗:传入 container={ref} 并设置 position="inline",将进度条限制在局部弹窗内部。
  • 弹簧滤波平滑(Smooth Spring):在高刷新率(120Hz/144Hz)屏幕或触控板快速滑动时,原生滚动事件存在微量离散阶梯跳跃。组件默认开启 smooth={true},通过精确阻尼弹簧将阶跃信号滤波为自然流体动效。
  • 渐进显隐:环形进度条常与“返回顶部”浮动悬钮结合,可配合页面滚动超过 20% 时淡入显示。
  • 零进度隐藏:环形指示在 0% 时自动隐藏圆头端点,避免出现孤立的小圆点。

场景示例

协议条款通读验证

监听局部容器的滚动进度,只有当用户阅读至文末时才解锁“同意并继续”:

Loading…

目录章节进度

为每个章节分别传入 target,目录中的小圆环会随对应章节的阅读进度填满。点击目录项可在容器内平滑跳转:

Loading…

横向滚动

设置 axis="x" 监听横向列表,并通过 indicatorClassName 使用图表色作为强调:

Loading…

无障碍与交互 Accessibility

  • 标准 ARIA 进度条角色:组件自动挂载 role="progressbar"、aria-valuemin={0}、aria-valuemax={100} 以及动态响应的 aria-valuenow 属性,读屏软件可无障碍查询当前完成百分比。
  • 系统减少动态偏好(Reduced Motion):当检测到用户的系统开启 prefers-reduced-motion 时,组件将自动关闭 Spring 滤波插值,进度条直接实时绑定原生像素位置,避免不必要的物理回弹。