返回首页

IntersectionObserver 实现滚动揭示动画

2026-08-19 · 阅读约 6 分钟

「滚动到某个位置,元素淡入上浮」这个效果,早年的做法是监听 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));

动画只改 opacitytransform,走的是合成阶段,滚动时不会掉帧。

两个配置项

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 不只能做动画:

共同点都是「元素和视口的位置关系」,凡是这类需求,第一反应应该是 IntersectionObserver 而不是 scroll 事件。