「滚动到某个位置,元素淡入上浮」这个效果,早年的做法是监听 scroll 事件,在回调里调 getBoundingClientRect() 判断有没有进入视口。这套能用,但有两个硬伤:scroll 触发极其频繁,而 getBoundingClientRect() 会强制同步布局,两者叠在一起就是典型的滚动卡顿来源。
IntersectionObserver 就是为解决这个问题设计的。它由浏览器在合适的时机异步通知你「元素和视口的相交状态变了」,不占用滚动的关键路径。这个站上的进场动画就是用它做的,把实现记一下。
最小实现
CSS 负责两个状态和之间的过渡,JS 只负责在恰当的时机加个类:
.reveal {
opacity: 0;
transform: translateY(22px);
transition: opacity .75s cubic-bezier(.32,.72,0,1),
transform .75s cubic-bezier(.32,.72,0,1);
}
.reveal.in {
opacity: 1;
transform: none;
}
const io = new IntersectionObserver((entries) => {
entries.forEach((e) => {
if (e.isIntersecting) {
e.target.classList.add('in');
io.unobserve(e.target); // 只播一次
}
});
}, {
rootMargin: '0px 0px -12% 0px',
threshold: 0.1,
});
document.querySelectorAll('.reveal').forEach((el) => io.observe(el));
动画只改 opacity 和 transform,走的是合成阶段,滚动时不会掉帧。
两个配置项
threshold
元素可见比例达到多少才算「相交」。0 是露出一个像素就触发,1 是必须完全进入视口。做进场动画通常给 0.1 左右——露出一点点就开始动,比等到完全可见再动自然得多。
要注意:如果元素本身比视口还高(比如一段长文章),threshold: 1 永远不会满足,回调一次都不会触发。这是个很容易忽略的坑。
rootMargin
用来把判定区域相对视口撑大或收缩,语法和 margin 一样,正值外扩、负值内缩。
rootMargin: '0px 0px -12% 0px'
底部收进 12%,意思是元素要往上多走一段才触发。不加这个的话,元素刚在屏幕最下沿冒头就开始淡入,用户视线还没到那儿,动画就已经播完了。反过来,做图片懒加载时要用正值提前加载:
rootMargin: '200px 0px' // 距离视口还有 200px 就开始加载
另外 rootMargin 的百分比是相对 root(默认是视口)算的,不是相对被观察元素。
错峰
一组卡片同时淡入会显得很平,让它们依次进场效果好得多。不需要在 JS 里排队,用 CSS 变量传延迟就行:
<div class="card reveal" style="--d:0ms">…</div>
<div class="card reveal" style="--d:70ms">…</div>
<div class="card reveal" style="--d:140ms">…</div>
.reveal { transition-delay: var(--d, 0ms); }
间隔建议控制在 50–80ms。再大就显得拖沓,用户会明显感觉到「在等」。整组的总时长最好别超过 400ms。
unobserve 别忘了
触发过一次之后要 unobserve,否则元素滚出去再滚回来会重新播一遍。反复淡入淡出很烦人,而且持续持有观察目标也是不必要的开销。
如果确实需要来回都播(比如某些数据可视化),那就不 unobserve,改成在 !e.isIntersecting 时移除类。但大多数进场动画应该是一次性的。
降级:内容不能因为 JS 挂了就消失
这是最重要的一点。.reveal 的初始状态是 opacity: 0,如果 JS 因为任何原因没跑起来——脚本 404、被拦截、执行报错——整页内容就是全白的。搜索引擎抓取和无障碍工具也会受影响。
两道保险。第一道是 <noscript>:
<noscript>
<style>.reveal { opacity: 1; transform: none; }</style>
</noscript>
第二道是在 JS 里显式处理不支持的情况,顺便把 reduced-motion 一起兜住:
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const els = document.querySelectorAll('.reveal');
if (reduced || !('IntersectionObserver' in window)) {
els.forEach((el) => el.classList.add('in')); // 直接显示,不做动画
} else {
// …正常观察
}
reduced-motion 下不是「动画放慢」而是「直接到位」,这一点容易做错。
首屏元素的处理
页面顶部的元素在 JS 执行时就已经在视口里了,观察器会立刻触发回调,于是它们也会播一遍淡入。这个行为本身没错,但如果你希望首屏是「加载后主动播放的错峰进场」而不是「滚动揭示」,那就该用另一套:
.ready .rise {
animation: rise .8s cubic-bezier(.32,.72,0,1) forwards;
animation-delay: var(--d, 0ms);
}
@keyframes rise {
to { opacity: 1; transform: none; }
}
JS 里给 body 加上 ready 类就开始播。首屏用 .rise、下面的内容用 .reveal,两者互不干扰。
其他用得上的场景
这个 API 不只能做动画:
- 图片懒加载——虽然现在
loading="lazy"基本够用了,但需要精细控制加载时机时还是得手写。 - 无限滚动——在列表末尾放一个哨兵元素,观察到它就加载下一页,比算滚动距离干净得多。
- 目录高亮——观察各个小标题,同步高亮侧边目录里对应的条目。
- 曝光埋点——统计某个模块是否真的被用户看到过,以及看了多久。
共同点都是「元素和视口的位置关系」,凡是这类需求,第一反应应该是 IntersectionObserver 而不是 scroll 事件。