配了三年导航栏,今天发现一个 CSS 属性让动画自己会判断滚动方向——scroll() + timeline-scope 把这件事彻底原生化了

配导航栏滚动交互配了三年,今天发现一个 CSS 属性让动画自己会判断「往上滚还是往下滚」——scroll() 双向动画把这件事彻底原生化了。

以前写这种交互,要绑两个 scroll 事件:一个监听滚动方向,一个计算距离。现在两行 CSS 全搞定,浏览器自己算,刷新页面也不乱。

01 scroll() 不只是「滚动时触发动画」

很多人以为 scroll() 就是「一滚动就执行动画」,其实它给你的是一个进度条——0% 到 100%,代表从滚动起点到终点的位置。向上滚动和向下滚动各走一遍这个进度条。

这个区别在双向交互里是关键。拿导航栏来说:

@keyframes shrink-header {
  from { padding: 20px 0; font-size: 18px; }
  to { padding: 8px 0; font-size: 14px; }
}

nav {
  animation: shrink-header linear;
  animation-timeline: scroll(nearest block);
}

这串代码让导航栏跟着滚动距离走:从顶部滚到最底部,padding 和 font-size 从大到小线性变化。问题是——不管往上滚还是往下滚,动画都在往前走

现实体验是:页面滚下去,导航栏缩了;再往上滚一点,导航栏又胖回来了。这个「回退」效果在大多数场景是对的,但如果你的交互只需要单向效果——比如导航栏一旦滚过某个点就保持收缩——就需要控制方向了。

02 scroll(both) 把双向都给我,我不想自己选

Chrome 129 之后,scroll() 支持第一个参数传 both,表示双向都跟踪。用法上跟 block 没有语法区别:

animation-timeline: scroll(both block);

但实际效果变了:向上滚动和向下滚动各走一遍 0%→100% 的进度。听起来和默认行为一样——默认本来就是双向的

区别在于你如何解读这个进度。导航栏收缩的案例里,默认行为(block)已经够用,因为向上滚触发回退在语义上是对的。

真正的区别出现在你想让某个动画「只往一个方向走」的时候——比如一个进度指示器,滚到哪就是哪,不往回走。

.scroll-indicator {
  animation: progress linear;
  animation-timeline: scroll(nearest block);
}

@keyframes progress {
  from { transform: scaleX(0); }
  to { transform: scaleX(1); }
}

这个进度条,滚下去走 0→100%,滚上来呢?进度条往回走了。如果你不想要这个回退,CSS 目前没有直接「单向 scroll」的值。

这个局限是真实存在的。如果你需要「只跟向下滚动走,不跟向上滚走」,目前还是要靠一点 JS 或者换一个思路——用 view() 替代 scroll(),让动画跟着元素进入视口的方向走,而不是跟着滚动条走。

这是 scroll() 双向能力的边界,不是缺陷。 知道它能双向和知道它什么时候不够用,同样重要。

03 timeline-scope:让时间线跨过 DOM 边界

scroll() 的第二个限制是:动画和时间线必须在同一个滚动容器的子树里。子元素用 scroll(nearest) 只能找最近的祖先滚动容器,不能跨容器取时间线。

这个限制在真实项目里会出现。比如一个侧边栏固定在页面右侧,但你要让侧边栏里的进度条跟着主内容区的滚动走——用默认的 scroll() 做不到。

timeline-scope 就是来解决这个的。它的作用是把一个具名时间线的范围扩大,扩大到DOM树上的任意祖先元素。

工作原理分三步:

  1. 定义时间线:在滚动容器上用 scroll-timeline-name 起个名字
.main-scroll {
  scroll-timeline-name: --page-scroll;
  scroll-timeline-axis: block;
}
  1. 扩大范围:在祖先元素上用 timeline-scope 把这个名字的范围提升上来
.layout-wrapper {
  timeline-scope: --page-scroll;
}
  1. 使用时间线:在任何后代元素上通过名字引用这个时间线
.sidebar-indicator {
  animation: progress linear;
  animation-timeline: --page-scroll;
}

现在侧边栏的进度条跟着主内容区的滚动走了,完全不需要 JS。

这个组合最值钱的地方在于解耦。 主内容区和侧边栏是两个独立的组件,之前要联动必须通过 JS 传参或者共享状态。现在只需要在父级加一行 timeline-scope,两个组件在 DOM 结构上完全独立,动画逻辑却天然打通。

04 降级方案:@supports 渐进增强

scroll() + timeline-scope 是 Chrome 115+ Baseline 2024 的能力,Firefox 101+、Safari 17+ 部分支持。生产环境使用建议加 @supports:

/* 有支持时用原生 */
@supports (animation-timeline: scroll()) {
  nav {
    animation: shrink-header linear;
    animation-timeline: scroll(nearest block);
  }

  .layout-wrapper {
    timeline-scope: --page-scroll;
  }

  .main-scroll {
    scroll-timeline-name: --page-scroll;
  }
}

/* 没有支持时回退:纯 CSS 或者 JS 降级 */

实际项目中,大多数用户的浏览器已经支持了,但加上 @supports 不花多少成本。

05 实战建议:这三个场景最值得用

1. 导航栏跟随滚动收缩/展开——最常见,不需要 JS,两行 CSS 搞定。

2. 阅读进度指示器——进度条跟着页面滚动走,用 scroll(root block) 取整页滚动轴。

3. 跨容器联动——侧边栏跟着主内容区滚动,用 timeline-scope 扩大时间线范围,两个模块独立但动画联动。

如果你现在还在用 Intersection Observer 监听滚动来做进度条,或者用 scroll 事件算距离,建议把这三个场景跑一遍,感受一下「浏览器原生能力」和「自己写逻辑」的区别——不只是少几行代码,是让渲染引擎自己来处理这件事,60fps 是默认结果,不是优化目标。

评论区

0 条评论

登录后可评论。

阿柯·前端架构 654 阅读