局部滚动文章
把多段正文放在这里,使 reader 的内容高度超过自身高度。
进度条会随着 reader 的 block 轴滚动而变化。
如果页面内容比较长,阅读进度条不一定要依赖 JavaScript 监听 scroll 事件。CSS 的 animation-timeline 可以把动画从默认的“按时间播放”改成“按滚动进度播放”,再配合 scroll(),就能让一个元素的宽度从 0% 逐渐变成 100%。
最小做法是:给页面或内容区制造真实的滚动轴,让进度条绑定 animation-timeline: scroll(),然后用关键帧改变它的宽度。若目标环境不支持滚动驱动动画,就保留一个静态的基础样式。
这类需求最容易混淆的是“动画”和“时间线”。普通 CSS 动画默认使用文档时间线,页面加载后经过多少时间,动画就推进多少;animation-timeline 则允许把推进来源换成滚动进度或元素可见度。
本文只做阅读进度条:滚动容器提供进度,scroll() 生成匿名滚动进度时间线,@keyframes 描述宽度变化,进度条负责显示结果。view() 会在兼容边界一节对比,但不把它混入主实现。
| 部分 | 职责 | 排查重点 |
|---|---|---|
| 滚动容器 | 产生可计算的滚动距离 | 是否真的有溢出和滚动条 |
scroll() | 选择滚动来源和方向 | scroller、axis 是否选对 |
@keyframes | 把时间线进度映射成样式 | 关键帧是否改变了目标属性 |
| 进度条 | 把 0% 到 100% 变成可见宽度 | 定位和尺寸是否不会遮住内容 |
先看一个不依赖脚本的最小版本。示例让整个文档作为滚动来源,进度条固定在页面顶部;动画本身只负责把伪元素的 transform 从 0 缩放到 1。
一篇需要滚动阅读的文章
这里放置正文内容,内容高度应当超过视口。
实际项目可以继续放置段落、代码和图片。
/* 进度条固定在顶部,但不参与鼠标命中 */
.reading-progress {
position: fixed;
inset: 0 0 auto;
height: 4px;
background: #1b75d0;
transform-origin: left center;
transform: scaleX(0);
z-index: 10;
pointer-events: none;
}
/* 用关键帧把滚动时间线映射成从 0 到 1 的横向缩放 */
@keyframes reading-progress-width {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
/* scroll() 默认寻找最近的滚动祖先;页面滚动时也可显式使用 root */
.reading-progress {
animation: reading-progress-width linear both;
animation-timeline: scroll(root block);
}
这里的 linear 只是让关键帧按时间线线性推进,真正改变动画进度的是最后一行 animation-timeline。不要只写 animation 而漏掉时间线,否则进度条会按默认文档时间线播放。

scroll() 可以接滚动来源和轴向两个参数。nearest 表示最近的可滚动祖先,root 表示根元素,self 表示当前元素自身;轴向可以写 block、inline、x 或 y。
如果你要做一个局部文章容器的进度条,最好把时间线绑定到这个容器,而不是让进度条误跟随整个页面。下面把滚动区域明确放在 .reader 上,并让进度条元素位于该区域内部。
局部滚动文章
把多段正文放在这里,使 reader 的内容高度超过自身高度。
进度条会随着 reader 的 block 轴滚动而变化。
/* 让 reader 成为最近的垂直滚动祖先 */
.reader {
position: relative;
height: 28rem;
overflow-y: auto;
border: 1px solid #d8e1ec;
}
/* 局部进度条固定在 reader 顶部,避免盖住正文点击区域 */
.reader-progress {
position: sticky;
inset-block-start: 0;
height: 4px;
background: #0f8b8d;
transform: scaleX(0);
transform-origin: left center;
z-index: 1;
pointer-events: none;
animation: reader-progress linear both;
/* reader 是最近滚动祖先,block 表示随垂直阅读方向推进 */
animation-timeline: scroll(nearest block);
}
/* 关键帧只描述显示属性,不负责寻找滚动容器 */
@keyframes reader-progress {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
如果容器使用横向滚动,改成 scroll(nearest inline) 或 scroll(x)。如果指定的轴没有滚动条,时间线会处于不活动状态,动画看起来就像完全没有生效。因此,调试时先确认 overflow、容器尺寸和内容溢出,再检查关键帧。
滚动驱动动画属于浏览器平台能力,不能假定所有访问者都支持。最实用的降级方式是先给进度条一个不依赖动画的基础外观,再用 @supports 在支持时启用时间线。这样即使动画规则被忽略,页面也不会出现一条完全不可见的装饰层。
/* 基础回退:不支持滚动时间线时仍然保留清晰的顶部标记 */
.reading-progress {
width: 100%;
background: #d7e8f6;
}
/* 只有浏览器理解 scroll() 时才启用动态宽度映射 */
@supports (animation-timeline: scroll()) {
.reading-progress {
width: auto;
animation: reading-progress-width linear both;
animation-timeline: scroll(root block);
}
}
/* 尊重用户的减少动态效果偏好,保留静态提示而不强制播放动画 */
@media (prefers-reduced-motion: reduce) {
.reading-progress {
animation: none;
transform: scaleX(1);
}
}
view() 与 scroll() 的关注点不同:前者根据目标元素在滚动视口中的可见程度推进,适合做卡片进入动画;后者根据滚动容器已经滚过的距离推进,更适合本文的阅读进度条。两者都依赖明确的滚动上下文,不能用一个没有溢出的普通容器代替。

遇到进度条停在原地时,建议按下面的顺序检查,通常不需要立刻改成 JavaScript:
scroll() 时间线。block 或 y,横向列表用 inline 或 x。书写模式变化时,逻辑轴比固定物理轴更稳妥。width,目标元素不能又被固定宽度覆盖;使用 transform: scaleX() 时要保留正确的 transform-origin。animation 简写可能重置 animation-timeline。先写动画简写,再写 animation-timeline,不要在后面再次用简写覆盖它。@supports 条件、层叠顺序和 prefers-reduced-motion 是否把动态规则改成了静态状态。/* 用支持条件做最小排查,不在不支持的环境中强行覆盖样式 */
@supports (animation-timeline: scroll()) {
.reading-progress {
/* 先设置动画参数,再在下一行指定时间线,避免被简写重置 */
animation: reading-progress-width linear both;
animation-timeline: scroll(root block);
}
}
/* 不支持时保留静态底色,帮助用户识别页面顶部的阅读提示 */
@supports not (animation-timeline: scroll()) {
.reading-progress {
transform: scaleX(1);
}
}
| 需求 | 写法 | 注意点 |
|---|---|---|
| 跟随页面垂直滚动 | scroll(root block) | 根页面必须有可滚动内容 |
| 跟随最近局部容器 | scroll(nearest block) | 最近祖先要有滚动条 |
| 跟随横向滚动 | scroll(nearest inline) | 确认 inline 轴确实溢出 |
| 浏览器不支持 | @supports not (...) | 保留静态颜色或静态宽度 |
| 用户减少动态效果 | prefers-reduced-motion: reduce | 关闭动画并保留可理解的状态 |
核心顺序可以压缩成一句话:先让容器产生真实滚动,再选择 scroll() 的来源和轴,接着用关键帧描述视觉变化,最后用 @supports 和减少动态效果媒体查询补上边界。这样一个阅读进度条就能保持纯 CSS,同时在不支持新时间线的环境中仍有可见的基础反馈。
优先检查目标滚动轴是否存在滚动条,再检查 animation 简写是否在后面重置了时间线,以及关键帧是否真的改变了目标元素。
不能完全替代。scroll() 表达滚动容器的整体进度,view() 表达某个元素进入和离开视口的可见度进度,适用的阅读模型不同。
不一定,但要明确时间线寻找的滚动祖先。放在局部容器内时更容易使用 scroll(nearest ...);放在页面外层时则要显式考虑根文档滚动。
CSS anchor-size怎样让弹出层宽度跟随触发按钮