12k
All articles

如何创建阅读进度条

使用 JavaScript 或 CSS 滚动驱动动画创建阅读进度条,涵盖正确的滚动计算、性能优化和无障碍处理。

OpenReplay Team
OpenReplay Team
如何创建阅读进度条

阅读进度条是一个纤细的固定指示器(通常固定在视口顶部),随着读者滚动浏览长篇文章,它会从 0% 填充到 100%。

我发布的第一个版本在文章结束前大约三屏的位置就到达了 100%,因为它悄悄地把评论区和页脚也一起计入了文章的测量范围。事实证明,把这个细节处理正确,就是这项工作的大部分内容。

你可以用两种方式来构建它:一种是通过 JavaScript 滚动监听器,根据滚动百分比计算来设置进度条的宽度;另一种是完全不使用 JavaScript 的纯 CSS 滚动驱动动画。本指南会同时介绍这两种方式,包括面向整个文档和面向文章范围的正确滚动计算方法、让滚动处理函数保持低开销的性能细节,以及上线前你需要处理的无障碍访问和渐进增强问题。

核心要点

  • 对于面向整个文档的进度条,滚动进度为 scrollTop / (scrollHeight − clientHeight) × 100;对于仅追踪文章的进度条,则需要测量 <article>window.scrollY / ((article.clientHeight + article.offsetTop) − window.innerHeight) × 100
  • 当页面包含相关文章模块、评论区或较高的页脚时,请使用面向文章范围的公式,这样进度条会在文章结尾处达到 100%,而不是在页面底部。
  • 由于 scroll 事件几乎在每一帧都会触发,请在 requestAnimationFrame 内执行宽度更新,并缓存高度读取值,仅在 resize 时重新计算,这样处理函数就永远不会强制触发同步布局。
  • 纯 CSS 版本无需 JavaScript:给固定定位的进度条设置 animation-timeline: scroll(),编写一个将 transformscaleX(0) 动画到 scaleX(1)@keyframes,以及 animation-duration: 1ms——这正是 Firefox(在开启标志位后或在 Nightly 版本中)应用该动画所必需的。
  • 滚动驱动动画已在 Chrome/Edge 115+、Safari 26+ 和 Opera 中提供,但尚未成为 Baseline,因为稳定版 Firefox 仍将其隐藏在标志位之后。请将纯 CSS 进度条视为渐进增强。

什么是阅读进度条,什么时候该用它?

阅读进度条通过一条在屏幕顶部延伸的条形,将”这篇文章还剩多少”以视觉方式呈现出来。它适用于长篇内容(深度教程、随笔、文档),在这些场景中,读者能从位置感中获益——而现代的纤细滚动条已经无法提供这种位置感了。在短页面、落地页,或任何一到两屏就能看完的内容上,它只会增加视觉噪音而不能提供任何信息;这些地方就别用了。

有两个设计决策决定了后续的整个构建过程:进度条测量哪个区域(整个文档还是仅文章正文),以及你用 JavaScript 还是 CSS 来实现它。

如何计算阅读进度?

把计算搞对,其他一切就顺理成章了。根据你希望进度条代表什么,有两个正确的公式。

整个文档的滚动。 对于随整个页面滚动而填充的进度条,进度等于已滚动距离除以最大可滚动距离:

progress = scrollTop / (scrollHeight − clientHeight) × 100

分母中减去可见高度,是因为你永远无法把最后一个视口高度的内容滚动出视野之外:到达页面底部时,仍有整整一屏内容可见。在根滚动容器上,scrollHeight 是内容总高度,而 clientHeight 是可见高度。

面向文章范围的滚动。 面向整个文档的进度条会把页脚、评论和相关文章模块都算进去,所以它会在页面底部而非文章结尾处达到 100%。要解决这个问题,就改为测量 <article> 元素:

distance = (article.clientHeight + article.offsetTop) − window.innerHeight
progress = window.scrollY / distance × 100

这里的 distance 是从首次绘制到文章底部边缘进入视野这一时刻之间的滚动轨迹长度。当你的页面在文章下方还有实质性内容时,使用面向文章范围的公式;当可滚动内容就是整个页面时,使用文档公式。请注意,offsetTop 是相对于最近的已定位祖先元素测量的,所以要让这个数值真正表示”距页面顶部的距离”,就需要保持文章处于正常文档流中。

JavaScript 实现

JavaScript 方案在所有浏览器中都能工作,而且是获得精确的文章范围进度的唯一方式。你需要一个固定定位的进度条元素、少量 CSS 和一个滚动处理函数。

<div id="progress-bar" aria-hidden="true"></div>
#progress-bar {
  position: fixed;
  top: 0;
  left: 0;
  width: 0;
  height: 4px;
  background: linear-gradient(to right, #7b2ff7, #f107a3);
  z-index: 9999;
}
const bar = document.getElementById("progress-bar");
const article = document.querySelector("article");
let distance = 0;
let ticking = false;

function measure() {
  distance = (article.clientHeight + article.offsetTop) - window.innerHeight;
}

function update() {
  const progress = Math.min((window.scrollY / distance) * 100, 100);
  bar.style.width = `${progress}%`;
  ticking = false;
}

function onScroll() {
  if (!ticking) {
    requestAnimationFrame(update);
    ticking = true;
  }
}

window.addEventListener("load", () => { measure(); update(); });
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", measure);

测量在 load 处理函数中执行,这样图片和字体已经稳定下来,clientHeight 才是准确的。如果你想要一个面向整个页面的进度条,把面向文章范围的 distance 换成文档公式即可。

保持滚动处理函数的高效

scroll 事件几乎会在每一个动画帧触发,所以一个在每次事件中都读取布局并写入样式的朴素处理函数,必然会成为卡顿的来源。有两条规则能让它保持低开销。

首先,使用上面的 ticking 标志把视觉写入批处理到 requestAnimationFrame 中,这样无论 scroll 触发得多频繁,你每帧最多只更新进度条一次。其次,缓存你的高度读取值。在每次滚动事件中读取 clientHeight/offsetTop 会强制浏览器刷新待处理的布局,而这些反复的重排正是布局抖动(layout thrashing)在实践中的表现形式,所以只计算一次 distance,并且仅在 resize 时重新计算。生产环境中一种常见的失败模式正是如此:一个未做节流的监听器在每次事件中读取几何信息并写入 width,而滚动密集型页面的会话回放经常会暴露由此产生的掉帧问题。将监听器注册为 { passive: true } 也能告知浏览器你不会调用 preventDefault,从而让滚动保持流畅。

纯 CSS 阅读进度条

你可以使用 CSS 滚动驱动动画,在零 JavaScript 的情况下构建进度条。把动画绑定到滚动时间轴而不是流逝的时间,浏览器就会根据滚动位置来驱动进度条的水平缩放。由于该动画作用于 transform 而非布局属性,它可以运行在合成器(compositor)上,而不必经过主线程上的滚动监听器。

<div id="reading-progress" aria-hidden="true"></div>
@supports (animation-timeline: scroll()) {
  @media (prefers-reduced-motion: no-preference) {
    #reading-progress {
      position: fixed;
      top: 0;
      left: 0;
      width: 100%;
      height: 4px;
      z-index: 9999;
      background: #7b2ff7;
      transform: scaleX(0);
      transform-origin: left;
      animation-name: grow-progress;
      animation-timeline: scroll();
      animation-duration: 1ms; /* required so the animation runs in Firefox */
      animation-timing-function: linear;
    }
    @keyframes grow-progress {
      from { transform: scaleX(0); }
      to   { transform: scaleX(1); }
    }
    @media (prefers-color-scheme: dark) {
      #reading-progress { background: #fc0; }
    }
  }
}

进度条以全宽进行布局,然后用 transform: scaleX(0) 压缩为零,接着随着你滚动再被放大回来。transform-origin: left 是让它从左边缘而不是从中心开始生长的关键。改为动画化 width 看起来效果完全相同,但会在每一帧强制触发布局,从而把动画拉回到主线程上。

还有两个细节值得注意。在不带参数调用时,scroll() 会选取最近的可滚动祖先元素并跟随其块轴方向,对于大多数单列文章布局来说,这意味着根滚动容器;如果你想显式指定它,可以传入 root。另外,除非 animation-duration 为非零值,Firefox 会拒绝应用该动画,所以惯用的 1ms 正是让它在那里得以运行的原因,而同一个值也能在不支持该特性的浏览器中让进度条保持隐藏

最后这一点就是权衡所在。animation-timeline 尚未成为 Baseline。它已在 Chrome 和 Edge 115+、Safari 26+ 以及 Opera 中提供,而稳定版 Firefox 仍将其置于 layout.css.scroll-driven-animations.enabled 标志位之后,只在 Nightly 中默认开启。上面的 @supports 保护就是渐进增强的约定:支持的浏览器得到 CSS 进度条,其他浏览器则什么都不渲染,所以如果你需要覆盖所有浏览器,请搭配 JavaScript 版本作为回退方案。另请注意,纯 CSS 进度条测量的是整个滚动容器,所以它会像面向文档的 JS 公式一样,把页脚和评论内容都计算在内。

JavaScript 与纯 CSS:该用哪个

JavaScript 进度条纯 CSS 进度条
浏览器支持全部Chromium 115+、Safari 26+;Firefox 需开启标志位
文章范围精确度支持不支持,会计入整个页面
主线程开销滚动监听器每帧零开销,transform 运行在合成器上
是否需要 JavaScript需要不需要

当你需要进度条在文章结尾处停止,或者必须支持所有浏览器时,使用 JavaScript;当你想以最少的代码实现一个面向整个页面的指示器,并且可以把它当作增强特性时,使用纯 CSS 进度条。

无障碍访问与细节打磨

进度条属于装饰性界面元素,因此请将其标记为 aria-hidden="true",使其不进入无障碍树,从而远离屏幕阅读器的输出和焦点顺序。如果你确实希望播报数值,那就改用 role="progressbar" 配合实时更新的 aria-valuenow;不过对大多数阅读指示器而言,隐藏它才是正确做法。把 CSS 动画包裹在 @media (prefers-reduced-motion: no-preference) 中,这样选择减少动效的用户就不会看到一个正在动画的元素;同时选择一个与你的页头有足够对比度的进度条颜色,让它在浅色和深色主题下都保持可见。

两种方案产生的可见效果相同;JavaScript 版本换来的是文章范围的精确度和全面的浏览器支持,而纯 CSS 版本换来的是更小的实现体积,并把每帧的工作从主线程上移开。先从符合你目标浏览器范围的那一种开始,把滚动计算和 animation-duration: 1ms 这个细节完全按所示保留,如果你想兼得两者之长,就用 @supports 把它们叠加起来。

常见问题

为什么我的进度条在我读完文章之前就达到了 100%?

进度条测量的是整个文档而不是文章,所以它把页脚、评论和相关文章模块都算进了可滚动距离中。请切换到面向文章范围的公式:将 distance 计算为 (article.clientHeight + article.offsetTop) 减去 window.innerHeight,然后用 window.scrollY 除以该距离。这样进度条就会在文章底部而非页面底部达到 100%。

为什么纯 CSS 进度条在 Chrome 中有效,但在 Firefox 中无效?

Firefox 在其稳定版本中将滚动驱动动画置于 layout.css.scroll-driven-animations.enabled 标志位之后,该首选项仅在 Nightly 中默认开启,因此未开启标志位的 Firefox 什么也不会渲染。另外,除非 animation-duration 为非零值,Firefox 根本不会应用该动画,这就是大家都使用 1ms 这个值的原因。请为 CSS 进度条搭配 @supports 保护和一个 JavaScript 回退方案,以获得完整覆盖。

纯 CSS 进度条运行时不需要滚动事件监听器吗?

是的。CSS 滚动驱动动画将动画绑定到滚动时间轴而不是流逝的时间,因此浏览器直接根据滚动位置驱动进度条的 transform,无需 JavaScript 滚动监听器,也不需要在主线程上使用 IntersectionObserver。动画化 transform 而不是 width 正是让它对合成器友好的原因:在 Chromium 以及 Safari 26.4 或更高版本中,动画运行在合成器线程上,而更早的 Safari 26.x 版本则是在主线程上运行滚动驱动动画。动画化 width 或 height 会在每一帧强制触发布局,并在所有浏览器中把工作重新压回主线程。

阅读进度条应该暴露给屏幕阅读器吗?

对大多数阅读指示器而言,不应该。进度条属于装饰性界面元素,因此请将其标记为 aria-hidden='true',使其不进入无障碍树、远离屏幕阅读器输出,并排除在焦点顺序之外。只有当你确实需要播报数值时,才应改用 role='progressbar' 并配合实时更新的 aria-valuenow 属性;但对于纯视觉的阅读指示器,隐藏它才是正确的默认做法。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.