滚动进度 Scroll Progress
实时计算整页、特定滚动容器或指定 DOM 节点的滚动通过比例,支持线性条状与环形仪表两种形态,并内置弹簧滤波平滑算法。
基础用法
在文章滚动容器中,吸顶标题栏右侧的环形指示与底边细线同步显示阅读进度:
安装与引入
通过 CLI 自动添加组件,或手动复制源码至项目中:
pnpm dlx @wui-design/cli@latest add @wui/scroll-progresspnpm add motion lucide-react clsx tailwind-mergecomponents/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`。 |
| container | React.RefObject<HTMLElement | null> | — | 指定被监听的自定义可滚动容器 DOM 引用。若不提供则默认监听整页 Window 滚动。 |
| target | React.RefObject<HTMLElement | null> | — | 指定特定目标元素 DOM 引用,仅根据该元素在视口中的穿越过程计算进度。 |
| offset | UseScrollOptions["offset"] | — | 与 target 配合使用的滚动偏移计算区间,如 ["start start", "end end"]。 |
| size | number | 44 | 环形形态(variant="circle")的外圆直径像素尺寸。 |
| strokeWidth | number | 3 | 环形形态(variant="circle")的圆环线条描边粗细像素尺寸。 |
| showValue | boolean | true | 环形形态是否在中心显示百分比数字。尺寸较小时建议关闭。 |
| smooth | boolean | true | 是否启用 Spring 弹簧阻尼物理滤波,消除鼠标滚轮或触控板惯性回弹的骤变抖动。 |
| trackClassName | string | — | 应用于未填充背景底轨的额外 CSS 类名。 |
| indicatorClassName | string | — | 应用于实际流动填充指示条/环形高亮描边的额外 CSS 类名。 |
| className | string | — | 应用于最外层容器元素的额外 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% 时自动隐藏圆头端点,避免出现孤立的小圆点。
场景示例
协议条款通读验证
监听局部容器的滚动进度,只有当用户阅读至文末时才解锁“同意并继续”:
目录章节进度
为每个章节分别传入 target,目录中的小圆环会随对应章节的阅读进度填满。点击目录项可在容器内平滑跳转:
横向滚动
设置 axis="x" 监听横向列表,并通过 indicatorClassName 使用图表色作为强调:
无障碍与交互 Accessibility
- 标准 ARIA 进度条角色:组件自动挂载
role="progressbar"、aria-valuemin={0}、aria-valuemax={100}以及动态响应的aria-valuenow属性,读屏软件可无障碍查询当前完成百分比。 - 系统减少动态偏好(Reduced Motion):当检测到用户的系统开启
prefers-reduced-motion时,组件将自动关闭 Spring 滤波插值,进度条直接实时绑定原生像素位置,避免不必要的物理回弹。