CSS滚动驱动动画实录:用scroll-timeline让页面叙事活起来

2026-06-21 0 822

上个月公司官网改版,设计师拿过来一个长页面,要求滚动的时候有进度条指示、图片滑入、背景色渐变切换,甚至某个区域要有一点视差味道。我下意识打开npm找动画库,结果被一个前端同事拦住了:”现在浏览器原生就能干这事,你试试scroll-timeline,别再加依赖了。”

我一开始是拒绝的——总觉得这种新特性还不稳。但看完MDN的兼容性表又实际测了一遍,Chrome 115+、Edge 115+都已经支持,Firefox也在跟进,移动端也有相当可观的覆盖率。最关键的是,它写起来比我想象中简单太多,而且不需要任何polyfill就能在主流浏览器上跑。这篇文章就把我这一个月在项目里实战的几个场景整理出来,附上完整代码和你可能会遇到的坑。

一、传统JS做滚动动画到底烦在哪里

在接触scroll-timeline之前,我写滚动驱动的效果基本是这个套路:

  • 监听window.scroll事件,拿到scrollY
  • 算一堆百分比,再用requestAnimationFrame去更新DOM
  • 页面复杂的时候还得加IntersectionObserver来优化性能
  • 组件卸载时要手动解绑事件,不然内存泄漏

听起来就是那种”明明需求很简单,但实现起来要处理一堆边界条件”的典型场景。尤其是当页面里有多个不同的滚动区域(比如某个

内部也有overflow:scroll),事情就变得更加烦人。而CSS滚动驱动动画做的事就是把这些计算从JS搬到渲染引擎里,浏览器自己知道滚动了多少、元素在视口中处于什么位置,直接用这些信息驱动动画属性。

核心优势就两个:性能更好(因为动画运行在合成器线程,不占用主线程),代码量至少砍掉70%

二、先搞懂两条线:scroll进度和view进度

CSS滚动驱动动画目前有两个主要的功能函数——scroll()view()。它们分别对应两种不同的参照系。

2.1 scroll() —— 基于滚动容器的进度

简单理解就是:某个滚动条从顶部滚到底部,这个过程的百分比。你可以把它绑定到animation-timeline属性上,让动画的播放进度跟着滚动进度走。

/* 假设页面的滚动容器是根元素 */
@keyframes growWidth {
    from { width: 0%; }
    to   { width: 100%; }
}

.progress-bar {
    animation: growWidth linear;
    animation-timeline: scroll(root);
    /* 上面这行的意思是:用根元素的滚动进度来驱动growWidth动画 */
}

scroll(root)里的root代表根滚动容器(就是document.documentElement的滚动条)。你也可以指定其他带有overflow: scroll的元素,但那个元素必须是一个实际的滚动容器,且它在DOM层级上应该是动画元素的祖先(这一点后面会详细说,我在这里踩过坑)。

2.2 view() —— 基于元素与视口的关系

这个更贴近我们常说的”元素进入视口”场景。view()跟踪的是一个元素从开始进入滚动容器的可视区域,到完全离开这个区域的过程。比如一个图片从视口底部冒出来,滑到中间,再从顶部离开——整个过程由view()捕获,并映射为0%到100%的进度。

@keyframes fadeInUp {
    from {
        opacity: 0;
        transform: translateY(40px);
    }
    to {
        opacity: 1;
        transform: translateY(0);
    }
}

.article-image {
    animation: fadeInUp 1s ease-out;
    animation-timeline: view();
    /* 图片进入视口时开始fadeInUp动画,离开视口时完成 */
}

view()还支持两个参数,用来控制动画的触发区间:view(block, start)。比如view(block 20% 80%)意味着元素顶部到达视口20%位置时动画开始,元素底部到达视口80%位置时动画结束。这个粒度的控制在我们做精细的叙事效果时特别有用。

三、实战案例一:顶部丝滑的阅读进度条

几乎每个长文章页面都会在顶部放一根进度条,随页面滚动慢慢变长。以前我一般用JS监听scroll然后计算百分比,现在几行CSS就能搞定。

3.1 HTML结构

<div class="reading-progress"></div>
<main class="article-content">
    <!-- 这里放长文章内容,足够撑出滚动条 -->
    <h1>文章标题</h1>
    <p>...大量段落...</p>
</main>

3.2 关键CSS

/* 进度条容器固定在顶部 */
.reading-progress {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 4px;
    background: #e0e0e0;
    z-index: 1000;
}

/* 进度条的填充部分,用伪元素实现 */
.reading-progress::before {
    content: '';
    display: block;
    height: 100%;
    background: linear-gradient(90deg, #4f46e5, #7c3aed);
    /* 动画:宽度从0到100% */
    animation: progress-grow linear;
    animation-timeline: scroll(root);
    transform-origin: left center;
}

@keyframes progress-grow {
    from { width: 0%; }
    to   { width: 100%; }
}

就是这么简单。没有事件监听,没有requestAnimationFrame回调,浏览器自己会根据页面滚动的实际像素精确映射动画进度。而且这个动画运行在合成器线程,即使在滚动过程中也保持丝滑。

有一点需要提醒:scroll(root)只能用于根滚动容器。如果你的滚动发生在某个内部的div里,比如一个设置了overflow-y: auto的侧边栏,那就需要把root换成那个滚动容器的具体引用。但目前CSS规范里引用命名滚动容器还比较受限,实际项目中如果必须追踪内部滚动,可能还是要用IntersectionObserver辅助,或者等待浏览器进一步完善支持。

四、实战案例二:图片的视口入场效果

文章详情页里经常有一堆配图,我们想让用户滚到图片附近时图片才从下方淡入滑上来,而不是一进页面就全部加载完动画。这个效果用view()实现最自然不过。

4.1 基本实现

/* 图片默认透明且下移,动画生效后恢复 */
.article-content img {
    opacity: 0;
    transform: translateY(40px);
    animation: image-reveal 0.8s ease-out forwards;
    animation-timeline: view();
    /* 注意这里没有写animation-duration,因为时间线由滚动接管 */
    animation-range: entry 0% cover 40%;
    /* entry 0%:元素刚进入视口底部时动画开始
       cover 40%:元素到达视口40%位置时动画结束 */
}

@keyframes image-reveal {
    from {
        opacity: 0;
        transform: translateY(40px);
    }
    to {
        opacity: 1;
        transform: translateY(0);
    }
}

这个animation-range属性是调节动画触发区间的关键。如果不写它,默认就是元素从进入视口底部到完全离开视口顶部,整个区间都是动画的播放范围。但通常我们希望图片在靠近视口中部的时候就完成动画,而不是快滚出屏幕了还在那儿慢悠悠地变,所以用cover 40%提前结束。

4.2 批量处理不同位置的图片

如果页面上有十张图片,每张都写一个@keyframes显然不现实。好在我们可以用相同的动画名称,然后通过animation-rangeanimation-delay做微调:

/* 所有图片共用同一套关键帧 */
.article-content img {
    animation: image-reveal 0.6s ease-out forwards;
    animation-timeline: view();
    animation-range: entry 10% cover 35%;
}

/* 偶数图片稍微错开一点视觉节奏 */
.article-content img:nth-child(even) {
    animation-range: entry 15% cover 40%;
}

实际效果就是:图片逐张进入视口时依次淡入上浮,节奏均匀但不死板。这种细微的差异在纯JS方案里通常需要分别计算每张图的getBoundingClientRect(),现在直接用CSS选择器批量处理,维护起来轻松很多。

五、实战案例三:卡片区域的联动背景渐变

这个需求稍微复杂一点。产品经理希望首页有一个”功能亮点”区域,里面有四张横向排列的卡片,随着这个区域从视口底部逐步进入并向上滚动,区域背景色从浅灰渐变成深蓝。说白了就是滚动位置控制背景色

这里的关键在于:动画的进度不是映射到元素的widthopacity,而是映射到background-color。只要关键帧里写的是可动画的属性,scroll-timeline都能驱动。

5.1 区域结构

<section class="feature-section">
    <div class="feature-section__inner">
        <div class="feature-card">卡片1</div>
        <div class="feature-card">卡片2</div>
        <div class="feature-card">卡片3</div>
        <div class="feature-card">卡片4</div>
    </div>
</section>

5.2 背景渐变动画

.feature-section {
    animation: bg-gradient-shift linear;
    animation-timeline: view();
    /* 整个区域从刚进入视口到完全离开视口,动画贯穿始终 */
    animation-range: entry 0% exit 100%;
}

@keyframes bg-gradient-shift {
    0% {
        background-color: #f5f5f5;
    }
    100% {
        background-color: #1e3a5f;
    }
}

注意这里用了exit 100%,意思是元素底部到达视口顶部时才完成整个动画。如果你希望渐变在区域完全进入视口后就结束,可以改成animation-range: entry 0% contain 100%,这样当区域刚好完全处于视口内部时动画就100%了,后续再滚动不再变化。

实际测试中我发现一个小问题:如果这个区域后面紧跟着另一个很长的区域,用户可能会持续往下滚很远,但背景色已经不再变化了。为了让动画的”播放感”更强,我们在产品层面最终决定让动画在cover 80%的位置结束,这样背景色变化集中在前半段,用户感知更明显。这就是设计师说的”叙事节奏”,调了几个值最后定在80%。

六、你不能忽略的兼容性策略

我必须诚实地说:scroll-timelineview-timeline这两兄弟目前还不是所有浏览器都支持。根据caniuse的数据,Chrome和Edge从115版本开始完整支持,Safari还在开发中,Firefox通过layout.css.scroll-driven-animations.enabled标志可以开启实验性支持。

但正因为如此,这个特性非常适合做渐进增强。不支持滚动驱动动画的浏览器会直接忽略animation-timeline属性,但animation本身还是会被正常解析——只不过动画会按照animation-duration在页面加载时立即播放一遍。

所以我的处理方式是:

  • 对于进度条这种”没有也行”的效果,直接把animation-duration设成一个极小值(比如0.001s),配合forwards填充模式,确保在不支持的浏览器上进度条始终是满的,不出现闪一下再消失的尴尬。
  • 对于图片入场这类”没有就很奇怪”的效果,用@supports查询做回退:
/* 只在支持scroll-timeline的浏览器中启用入场动画 */
@supports (animation-timeline: view()) {
    .article-content img {
        opacity: 0;
        animation: image-reveal 0.6s ease-out forwards;
        animation-timeline: view();
        animation-range: entry 0% cover 40%;
    }
}

/* 不支持的浏览器里图片默认就是可见的 */
.article-content img {
    opacity: 1;
    transform: none;
}

这样在不支持的浏览器中,图片依然是正常显示的,只是少了那个滑入效果。用户体验没有打折,而支持的浏览器则获得更好的视觉效果——标准的渐进增强思路。

七、我在项目中踩到的三个实在的坑

7.1 动画元素必须在滚动容器的后代中

这个坑浪费了我半天时间。我把进度条放在<body>的直接子级,然后用scroll(root)驱动,结果在Chrome里死活不生效。查文档才发现:scroll()跟踪的滚动容器必须是动画目标元素的祖先。但root(即html元素)和body之间有微妙的层级关系,在某些情况下body的直接子元素并不被认为是root的后代(这取决于overflow的设置)。

解决办法很简单:把需要滚动的进度条放在<body>内部足够深的位置,或者直接用position: fixed脱离文档流,这样层级关系就不会被混淆。后来我把进度条的HTML塞到<header>里,问题就消失了。

7.2 animation-range的百分比理解偏差

animation-range: entry 20% cover 80%这个写法里,百分比指的是滚动容器可视区域的位置,而不是元素自身的高度比例。我一开始以为是”元素自身20%的位置进入视口”,结果调出来的效果怎么都不对。正确理解是:entry 20%意味着元素的顶部触碰到视口20%高度位置时动画开始;cover 80%意味着元素的底部触碰到视口80%高度位置时动画结束。

这个细节在MDN上写了,但很容易被快速扫过。我建议初次使用的时候在浏览器DevTools里逐步调整数值,感受一下每个参数对动画触发时机的影响。

7.3 多个滚动驱动动画的性能叠加

理论上这些动画都运行在合成器线程,不会引起重排或重绘。但实际项目中如果一个页面上有几十个同时触发的view动画,还是能感觉到微小的帧率波动。尤其是在低端安卓设备上,我测试过20个以上并行的view()动画,偶尔会掉到50fps以下。

应对策略倒也简单:用animation-range把动画错开,不要让它们在同一时刻全部集中触发,同时给每个动画设定较短的持续时间(animation-duration设为0.4s到0.6s比较合适)。这样实际同时活动的动画数量就很少,帧率稳稳的。

八、几句实在的总结

CSS滚动驱动动画不是用来替代JS的万金油,但它确实解决了一类非常具体的需求:跟滚动位置强绑定的视觉效果。进度条、入场动画、视差、背景渐变,这些以前需要写一坨JS代码还要记得清理副作用的小功能,现在用几行CSS就能干净地实现。

我在实际项目里把它和传统的IntersectionObserver配合使用:滚动驱动的动画负责纯视觉层面的增强,Observer负责更复杂的业务逻辑(比如统计曝光、触发懒加载),两者各司其职,互不干扰。

目前浏览器支持还在快速扩展中,Safari团队已经在官方博客上确认正在实现,预计一到两个大版本内就会跟上来。对于现在就可以上线的项目,用@supports做渐进增强是最稳妥的策略——既享受了新特性带来的丝滑体验,又没有牺牲任何兼容性。如果你手头正好有一个长页面的项目要改版,不妨选一个最不关键的动画先试试水,我猜你改完之后会想把其他动画也一起换掉。

CSS滚动驱动动画实录:用scroll-timeline让页面叙事活起来
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请联系本站我们会及时删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 css CSS滚动驱动动画实录:用scroll-timeline让页面叙事活起来 https://www.taomawang.com/web/css/2259.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务