CSS Scroll-Driven Animations:纯 CSS 实现滚动驱动动画

3058 字
15 分钟
CSS Scroll-Driven Animations:纯 CSS 实现滚动驱动动画

前言:滚动动画的前世今生#

滚动驱动动画是 Web 上最常见的交互模式之一:页面顶部的阅读进度条、滚动时元素渐入、视差滚动效果……这些效果以往全部依赖 JavaScript,通常是监听 scroll 事件配合 requestAnimationFrame,或者使用 GSAP ScrollTrigger、Intersection Observer 等库。

// 传统方式:JS 监听 scroll
window.addEventListener('scroll', () => {
const progress = window.scrollY / (document.body.scrollHeight - window.innerHeight);
progressBar.style.width = `${progress * 100}%`;
});

这种方式有几个问题:

  1. 性能scroll 事件在主线程触发,频率极高,容易造成卡顿
  2. 复杂度:需要手动计算进度、处理边界情况、管理生命周期
  3. 不可组合:很难将滚动动画与 CSS 动画系统结合

CSS Scroll-Driven Animations 彻底改变了这一局面。它将滚动进度与 CSS 动画的时间线绑定,让浏览器在合成器线程(compositor thread)上高效执行动画,完全不阻塞主线程。

核心概念:Animation Timeline#

传统 CSS 动画由时间驱动——动画从开始到结束经过固定时长:

.box {
animation: fade-in 1s ease-out; /* 1秒内完成 */
}

Scroll-Driven Animations 引入了新的时间线类型——动画由滚动进度驱动:

.box {
animation: fade-in linear;
animation-timeline: scroll(); /* 由滚动进度驱动 */
}

这里不需要 animation-duration,因为动画的”时间”就是滚动进度(0% 到 100%)。

CSS Scroll-Driven Animations 提供了两种时间线:

时间线类型函数含义
Scroll Progress Timelinescroll()基于滚动容器的整体滚动进度
View Progress Timelineview()基于元素在滚动口中的可见进度

scroll():滚动进度时间线#

基本语法#

.element {
animation: my-animation linear;
animation-timeline: scroll();
}

scroll() 函数接受两个可选参数:

scroll(<scroller>, <axis>)
  • scroller:指定哪个滚动容器
    • nearest(默认):最近的可滚动祖先
    • root:文档根滚动容器(通常是 viewport)
    • self:元素自身(如果它是可滚动的)
  • axis:指定哪个轴的滚动
    • block(默认):块轴(通常是垂直方向)
    • inline:行轴(通常是水平方向)
    • y:垂直轴
    • x:水平轴
/* 基于根滚动容器的垂直滚动 */
.element {
animation-timeline: scroll(root block);
}
/* 基于最近可滚动祖先的水平滚动 */
.element {
animation-timeline: scroll(nearest inline);
}
/* 基于自身的垂直滚动 */
.scrollable-box {
animation-timeline: scroll(self);
}

实战:阅读进度条#

这是最经典的滚动动画用例——页面顶部的阅读进度指示条:

<div class="progress-bar"></div>
<article>
<h1>很长的文章标题</h1>
<p>很长的文章内容...</p>
<!-- 很多内容 -->
</article>
.progress-bar {
position: fixed;
top: 0;
left: 0;
height: 4px;
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
transform-origin: left;
z-index: 1000;
/* 动画:从 scaleX(0) 到 scaleX(1) */
animation: grow-progress linear;
animation-timeline: scroll(root);
}
@keyframes grow-progress {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}

就这么简单。 不需要一行 JavaScript,进度条就能随页面滚动平滑增长。而且由于 transform 动画在合成器线程执行,性能极佳——60fps 毫无压力。

实战:滚动驱动的导航栏变化#

.navbar {
position: fixed;
top: 0;
width: 100%;
z-index: 100;
animation: navbar-shrink linear;
animation-timeline: scroll(root);
/* 只在滚动了一小段后触发 */
animation-range: 0px 200px;
}
@keyframes navbar-shrink {
from {
padding: 1.5rem 2rem;
background: transparent;
backdrop-filter: blur(0);
box-shadow: none;
}
to {
padding: 0.75rem 2rem;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(12px);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
}
/* logo 也跟着缩小 */
.navbar-logo {
animation: logo-shrink linear;
animation-timeline: scroll(root);
animation-range: 0px 200px;
}
@keyframes logo-shrink {
from {
height: 48px;
}
to {
height: 32px;
}
}

animation-range:控制动画范围#

animation-range 让你指定动画在滚动的哪个区间内执行:

/* 在滚动 0px 到 500px 的范围内执行动画 */
.element {
animation: my-anim linear;
animation-timeline: scroll(root);
animation-range: 0px 500px;
}
/* 使用百分比 */
.element {
animation-range: 0% 50%; /* 前半段滚动 */
}
/* 拆开写 */
.element {
animation-range-start: 100px;
animation-range-end: 500px;
}

view():视图进度时间线#

view() 是另一种时间线,它跟踪元素在滚动口中的可见性。当元素从一侧进入视口、穿过视口、从另一侧离开时,进度从 0% 变化到 100%。

基本语法#

view(<axis>, <inset>)
  • axis:与 scroll() 相同,默认 block
  • inset:调整滚动口的边界(类似 rootMargin
.card {
animation: fade-slide-in linear both;
animation-timeline: view();
}
@keyframes fade-slide-in {
from {
opacity: 0;
transform: translateY(50px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

view() 的进度阶段#

view() 时间线有几个关键阶段,可以通过 animation-range 精确控制:

范围名称含义
cover元素从开始进入到完全离开的整个范围(默认)
contain元素完全在视口内的范围
entry元素从开始进入到完全进入视口的范围
exit元素从开始离开到完全离开视口的范围
entry-crossing元素与视口入口边缘交叉的范围
exit-crossing元素与视口出口边缘交叉的范围
/* 只在元素进入视口时播放动画 */
.card {
animation: slide-in linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
/* 简写 */
.card {
animation-range: entry;
}
/* 在进入的后半段开始,到完全可见时结束 */
.card {
animation-range: entry 50% contain 0%;
}

实战:元素入场动画#

<section class="content">
<div class="card animate-on-scroll">
<h2>特性一</h2>
<p>描述内容...</p>
</div>
<div class="card animate-on-scroll">
<h2>特性二</h2>
<p>描述内容...</p>
</div>
<div class="card animate-on-scroll">
<h2>特性三</h2>
<p>描述内容...</p>
</div>
</section>
.animate-on-scroll {
animation: reveal linear both;
animation-timeline: view();
animation-range: entry 10% entry 90%;
}
@keyframes reveal {
from {
opacity: 0;
transform: translateY(60px) scale(0.95);
filter: blur(4px);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
filter: blur(0);
}
}

不同于 Intersection Observer 的”一次性触发”,这个动画是连续的——元素在进入视口的过程中逐渐显现,如果你向上滚动,它还会反向播放(逐渐消失)。

实战:带 stagger 效果的入场#

.card-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 2rem;
}
.card-grid .card {
animation: stagger-in linear both;
animation-timeline: view();
animation-range: entry;
}
@keyframes stagger-in {
from {
opacity: 0;
transform: translateY(40px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 利用 animation-delay 实现 stagger(使用负延迟不影响时间线) */
.card-grid .card:nth-child(1) { animation-delay: 0s; }
.card-grid .card:nth-child(2) { animation-delay: -0.1s; }
.card-grid .card:nth-child(3) { animation-delay: -0.2s; }

注意:在 scroll-driven 动画中,animation-delay 的行为有所不同。你可能需要用 animation-range 来实现真正的错开效果。

视差滚动效果#

视差(Parallax)是滚动动画中最经典的效果——不同层的元素以不同速度滚动,创造深度感。

<div class="parallax-section">
<div class="parallax-bg"></div>
<div class="parallax-content">
<h1>探索未知</h1>
<p>向下滚动开始旅程</p>
</div>
</div>
.parallax-section {
position: relative;
height: 100vh;
overflow: hidden;
}
.parallax-bg {
position: absolute;
inset: -20% 0;
background: url('mountain.jpg') center / cover;
/* 背景图以更慢的速度移动 */
animation: parallax-scroll linear;
animation-timeline: scroll(root);
}
@keyframes parallax-scroll {
from {
transform: translateY(-10%);
}
to {
transform: translateY(10%);
}
}
.parallax-content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: white;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
}

多层视差#

.layer-far {
animation: parallax-far linear;
animation-timeline: scroll(root);
}
.layer-mid {
animation: parallax-mid linear;
animation-timeline: scroll(root);
}
.layer-near {
animation: parallax-near linear;
animation-timeline: scroll(root);
}
/* 越远的层移动越慢 */
@keyframes parallax-far {
from { transform: translateY(0); }
to { transform: translateY(100px); }
}
@keyframes parallax-mid {
from { transform: translateY(0); }
to { transform: translateY(200px); }
}
@keyframes parallax-near {
from { transform: translateY(0); }
to { transform: translateY(350px); }
}

命名时间线:timeline-scope 与 scroll-timeline-name#

当动画元素不是滚动容器的后代时,需要使用命名时间线:

/* 定义命名的滚动时间线 */
.scroller {
overflow-y: scroll;
scroll-timeline-name: --my-scroller;
scroll-timeline-axis: block;
/* 简写 */
scroll-timeline: --my-scroller block;
}
/* 在祖先元素上声明 timeline-scope,让时间线可以跨子树共享 */
.layout {
timeline-scope: --my-scroller;
}
/* 使用命名时间线 */
.progress-indicator {
animation: grow linear;
animation-timeline: --my-scroller;
}
@keyframes grow {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}

类似地,view-timeline-name 可以创建命名的视图时间线:

.hero-section {
view-timeline-name: --hero;
view-timeline-axis: block;
/* 简写 */
view-timeline: --hero block;
}
.header {
animation: header-style linear both;
animation-timeline: --hero;
animation-range: exit;
}
@keyframes header-style {
from {
background: transparent;
}
to {
background: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
}

综合实战:完整的滚动叙事页面#

下面是一个综合运用各种技术的完整示例——一个滚动叙事(scrollytelling)页面:

<body>
<!-- 进度条 -->
<div class="scroll-progress"></div>
<!-- 导航 -->
<nav class="nav">
<div class="nav-logo">BRAND</div>
<div class="nav-links">
<a href="#intro">介绍</a>
<a href="#features">特性</a>
<a href="#pricing">价格</a>
</div>
</nav>
<!-- 英雄区域 -->
<section class="hero" id="intro">
<h1 class="hero-title">构建未来</h1>
<p class="hero-subtitle">下一代 Web 体验</p>
<div class="hero-graphic"></div>
</section>
<!-- 特性展示 -->
<section class="features" id="features">
<div class="feature-card">
<div class="feature-icon">⚡</div>
<h2>极速</h2>
<p>毫秒级响应,流畅如丝</p>
</div>
<div class="feature-card">
<div class="feature-icon">🔒</div>
<h2>安全</h2>
<p>企业级安全防护</p>
</div>
<div class="feature-card">
<div class="feature-icon">🎨</div>
<h2>美观</h2>
<p>精心设计每一个像素</p>
</div>
</section>
<!-- 更多内容... -->
</body>
/* ===== 进度条 ===== */
.scroll-progress {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, #3b82f6, #8b5cf6, #ec4899);
transform-origin: left;
z-index: 9999;
animation: progress-grow linear;
animation-timeline: scroll(root);
}
@keyframes progress-grow {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
/* ===== 导航栏滚动效果 ===== */
.nav {
position: fixed;
top: 0;
width: 100%;
padding: 1.5rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
z-index: 100;
transition: padding 0.3s;
animation: nav-compact linear both;
animation-timeline: scroll(root);
animation-range: 0px 300px;
}
@keyframes nav-compact {
from {
padding: 1.5rem 2rem;
background: transparent;
}
to {
padding: 0.75rem 2rem;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(16px);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
}
/* ===== 英雄区域视差 ===== */
.hero {
height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
position: relative;
overflow: hidden;
}
.hero-title {
font-size: clamp(3rem, 8vw, 8rem);
font-weight: 900;
animation: hero-text-parallax linear;
animation-timeline: scroll(root);
animation-range: 0vh 100vh;
}
@keyframes hero-text-parallax {
from {
transform: translateY(0);
opacity: 1;
}
to {
transform: translateY(-150px);
opacity: 0;
}
}
.hero-subtitle {
font-size: 1.5rem;
color: #6b7280;
animation: hero-sub-parallax linear;
animation-timeline: scroll(root);
animation-range: 0vh 80vh;
}
@keyframes hero-sub-parallax {
from {
transform: translateY(0);
opacity: 1;
}
to {
transform: translateY(-100px);
opacity: 0;
}
}
.hero-graphic {
position: absolute;
width: 500px;
height: 500px;
border-radius: 50%;
background: radial-gradient(circle, #dbeafe, #bfdbfe);
animation: hero-graphic-scale linear;
animation-timeline: scroll(root);
animation-range: 0vh 100vh;
}
@keyframes hero-graphic-scale {
from {
transform: scale(1);
opacity: 0.3;
}
to {
transform: scale(3);
opacity: 0;
}
}
/* ===== 特性卡片入场 ===== */
.feature-card {
padding: 2rem;
border-radius: 16px;
background: white;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
animation: card-enter linear both;
animation-timeline: view();
animation-range: entry 0% entry 100%;
}
@keyframes card-enter {
from {
opacity: 0;
transform: translateY(60px) scale(0.9);
}
50% {
opacity: 1;
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* 图标旋转入场 */
.feature-icon {
font-size: 3rem;
display: inline-block;
animation: icon-spin linear both;
animation-timeline: view();
animation-range: entry 20% entry 80%;
}
@keyframes icon-spin {
from {
transform: rotate(-180deg) scale(0);
opacity: 0;
}
to {
transform: rotate(0) scale(1);
opacity: 1;
}
}
/* ===== 尊重用户偏好 ===== */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
}
}

JavaScript 回退方案#

对于不支持 CSS Scroll-Driven Animations 的浏览器,提供优雅的回退:

/* 默认状态:不支持时元素正常显示 */
.feature-card {
opacity: 1;
transform: none;
}
/* 只在支持时应用滚动动画 */
@supports (animation-timeline: scroll()) {
.feature-card {
animation: card-enter linear both;
animation-timeline: view();
animation-range: entry;
}
}

或者用 Intersection Observer 作为 JS 回退:

// 特性检测
if (!CSS.supports('animation-timeline', 'scroll()')) {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.1 }
);
document.querySelectorAll('.animate-on-scroll').forEach((el) => {
observer.observe(el);
});
}
/* JS 回退样式 */
.animate-on-scroll {
opacity: 0;
transform: translateY(40px);
transition: opacity 0.6s ease, transform 0.6s ease;
}
.animate-on-scroll.is-visible {
opacity: 1;
transform: none;
}

浏览器兼容性#

截至 2026 年初:

特性ChromeFirefoxSafari
scroll()115+ ✅🟡 Flag
view()115+ ✅🟡 Flag
animation-range115+ ✅🟡 Flag
命名时间线115+ ✅🟡 Flag

目前 Chrome(和 Edge)支持最好,Firefox 在 flag 后面支持,Safari 暂不支持。建议:

  1. 使用 @supports 做渐进增强
  2. 提供 JS 回退方案(Intersection Observer)
  3. 确保不支持时内容仍然可用

性能优势#

CSS Scroll-Driven Animations 的最大优势之一是性能。与 JS 方案对比:

方面JS scroll 事件CSS Scroll-Driven
执行线程主线程合成器线程(compositor)
帧率依赖主线程负载稳定 60fps+
动画属性任意transform, opacity 等合成属性最佳
内存开销需要 JS 运行时极低
电池消耗较高较低

关键点:只要你的动画只涉及 transformopacity(合成属性),浏览器可以完全在 GPU 上执行,不触及主线程。这意味着即使主线程繁忙(大量 JS 执行),动画依然丝滑。

总结#

CSS Scroll-Driven Animations 代表了 Web 动画的未来方向——声明式、高性能、无 JavaScript

核心要点回顾:

  • scroll():基于滚动容器的整体滚动进度驱动动画
  • view():基于元素在视口中的可见性驱动动画
  • animation-range:精确控制动画在哪个滚动/可见范围内执行
  • 命名时间线:让动画可以引用非祖先的滚动容器
  • 性能卓越:在合成器线程执行,不阻塞主线程

虽然目前浏览器支持还在推进中,但在支持的浏览器中使用它作为渐进增强,可以极大地提升用户体验和开发效率。随着 Firefox 和 Safari 的跟进,它将成为 Web 动画的标准方案。

开始实践:从最简单的阅读进度条开始,然后尝试元素入场动画,最后挑战视差效果和复杂的滚动叙事页面。你会发现,以前需要几十行 JavaScript 的效果,现在几行 CSS 就能搞定。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

CSS Scroll-Driven Animations:纯 CSS 实现滚动驱动动画
https://boke.hackerdream.xyz/posts/css-scroll-driven-animations/
作者
晴天
发布于
2026-01-30
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
晴天
Hello, I'm 晴天.
公告
欢迎来到我的博客!这是一则示例公告。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
125
分类
17
标签
287
总字数
257,955
运行时长
0
最后活动
0 天前

目录