无需任何动画库,仅用原生 Web API 实现滚动时丝滑的淡入滑入效果,兼顾性能与体验。
你是否见过这样的交互动效:
- 用户滚动页面时,一组卡片像被“唤醒”一样,依次从下方滑入并淡入;
- 如果这些元素在页面加载时已在视口内,它们也会自动按顺序浮现。
这种效果不仅视觉流畅,还能有效引导用户注意力,提升内容层次感。更重要的是——它不依赖 GSAP、AOS 等第三方库,仅靠 Intersection Observer + CSS 动画 + 少量 JavaScript,就能实现高性能、可访问、且高度可控的滚动触发型级联动画。
今天,我们就来一步步拆解这个经典动效,并给出一套可直接复用的轻量级方案。
🔧 核心原理概览
整个动画系统依赖三个关键技术点:
| 技术 | 作用 |
|---|---|
IntersectionObserver | 监听元素是否进入视口,避免频繁 scroll 事件 |
CSS @keyframes | 定义滑入 + 淡入动画 |
--animation-order 自定义属性 | 通过 calc() 动态设置 animation-delay,实现“逐个延迟”的级联感 |
最关键的设计哲学是:动画只在用户能看到它的时候才执行,既节省性能,又避免“闪现”。
🧱 HTML 结构(简化版)
为便于理解,我们剥离业务逻辑,只保留动效核心:
<div class="container">
<ul class="card-list">
<li class="card scroll-trigger animate--slide-in" data-cascade style="--animation-order: 1;"
>Card 1</li
>
<li class="card scroll-trigger animate--slide-in" data-cascade style="--animation-order: 2;"
>Card 2</li
>
<li class="card scroll-trigger animate--slide-in" data-cascade style="--animation-order: 3;"
>Card 3</li
>
<!-- 更多卡片... -->
</ul>
</div>
💡 类名与属性说明
.scroll-trigger:表示该元素需要被滚动监听;.animate--slide-in:启用滑入动画;data-cascade:JS 识别“需设置动画顺序”的标志;--animation-order:CSS 自定义属性,用于计算延迟时间(如第 2 个元素延迟 150ms)。
🎨 CSS 动画定义
:root {
--duration-extra-long: 600ms;
--ease-out-slow: cubic-bezier(0, 0, 0.3, 1);
}
/* 仅在用户未开启“减少运动”时启用动画(晕动症用户友好) */
@media (prefers-reduced-motion: no-preference) {
.scroll-trigger:not(.scroll-trigger--offscreen).animate--slide-in {
animation: slideIn var(--duration-extra-long) var(--ease-out-slow) forwards;
animation-delay: calc(var(--animation-order) * 75ms);
}
@keyframes slideIn {
from {
transform: translateY(2rem);
opacity: 0.01;
}
to {
transform: translateY(0);
opacity: 1;
}
}
}
✨ 参数说明
| 属性 | 值 | 作用 |
|---|---|---|
transform | translateY(2rem) → 0 | 由下往上滑入 |
opacity | 0.01 → 1 | 淡入(避免完全透明导致布局跳动) |
animation-delay | n × 75ms | 第1个延迟75ms,第2个150ms……形成级联 |
animation-fill-mode | forwards | 动画结束后保持最终状态 |
✅ 无障碍提示:通过
@media (prefers-reduced-motion)尊重用户偏好,对晕动症用户更友好。
🕵️ JavaScript:Intersection Observer 监听逻辑
为什么不用 scroll 事件?
传统方式:
// ❌ 性能差,频繁触发
window.addEventListener('scroll', checkVisibility);
现代方案:
// ✅ 高性能,浏览器底层优化
const observer = new IntersectionObserver(callback, options);
完整监听逻辑
const SCROLL_ANIMATION_TRIGGER_CLASSNAME = 'scroll-trigger';
const SCROLL_ANIMATION_OFFSCREEN_CLASSNAME = 'scroll-trigger--offscreen';
function onIntersection(entries, observer) {
entries.forEach((entry, index) => {
const el = entry.target;
if (entry.isIntersecting) {
// 进入视口:移除 offscreen 类,允许动画播放
el.classList.remove(SCROLL_ANIMATION_OFFSCREEN_CLASSNAME);
// 若为级联元素,动态设置顺序(兜底)
if (el.hasAttribute('data-cascade')) {
el.style.setProperty('--animation-order', index + 1);
}
// 只触发一次,停止监听
observer.unobserve(el);
} else {
// 离开视口:加上 offscreen 类,禁用动画
el.classList.add(SCROLL_ANIMATION_OFFSCREEN_CLASSNAME);
}
});
}
function initScrollAnimations(root = document) {
const triggers = root.querySelectorAll(`.${SCROLL_ANIMATION_TRIGGER_CLASSNAME}`);
if (!triggers.length) return;
const observer = new IntersectionObserver(onIntersection, {
rootMargin: '0px 0px -50px 0px', // 元素进入视口 50px 后才触发
threshold: [0, 0.25, 0.5, 0.75, 1.0],
});
triggers.forEach((el) => observer.observe(el));
}
// 页面加载完成后启动
document.addEventListener('DOMContentLoaded', () => {
initScrollAnimations();
});
🎯 关键设计细节
rootMargin: '0px 0px -50px 0px':确保元素完全进入用户视野后再触发动画,避免“刚看到就结束”;- 初始所有
.scroll-trigger元素默认带有.scroll-trigger--offscreen类,阻止 CSS 动画生效; unobserve:动画只播放一次,避免重复触发,节省资源。
📊 两种场景下的行为对比
| 场景 | 初始状态 | 触发时机 | 动画表现 |
|---|---|---|---|
| 卡片已在视口内 | 无 --offscreen 类 | 页面加载后立即 | 依次淡入(基于 --animation-order) |
| 卡片在视口外 | 有 --offscreen 类 | 滚动到视口(超过 50px) | 滚动时依次淡入 |
这正是你感受到的“丝滑感”来源:无论用户如何进入页面,动画总是在最合适的时机出现。
💡 总结:这套方案的优势
| 能力 | 说明 |
|---|---|
| ✅ 高性能 | 使用 IntersectionObserver 替代 scroll 事件,避免频繁计算 |
| ✅ 精准控制 | 通过 rootMargin 和 threshold 灵活调整触发时机 |
| ✅ 无障碍友好 | 尊重 prefers-reduced-motion 用户偏好 |
| ✅ 轻量可复用 | 无依赖,仅 50 行 JS + 简洁 CSS,适合嵌入任何项目 |
| ✅ 懒加载兼容 | 可扩展用于图片懒加载、广告曝光统计等场景 |
附
完整 Demo 已上传 CodePen:
👉 codepen.io/AMingDrift/…
如果你正在开发电商、博客、SaaS 产品页等内容密集型网站,不妨将这套方案集成进去,给用户带来更优雅的浏览体验!
学习优秀作品,是提升技术的最佳路径。本文既是我的学习笔记,也希望对你有所启发。