说在前面
今天来实现一个 开箱流水线 Loading 动画 ------ 四个 3D 盒子依次"开箱",盖子翻开、墙壁展开、图标弹出,步骤之间用连接线串联,底部还有进度条同步推进。

在线体验
codepen
在线链接:codepen.io/yongtaozhen...

码上掘金
在线链接:code.juejin.cn/pen/7650046...

实现思路
整个效果可以拆成这几个关键步骤:
- 用 CSS 3D 搭建盒子 ------ 底面 + 四面墙 + 盖子,组成一个真实的立方体
- 设计铰链结构 ------ 盖子挂在背面墙上,翻盖动画才符合物理直觉
- 流水线时间轴 ------ 四个盒子交错触发,形成依次开箱的节奏感
- 缓动函数打磨 ------ 盖子翻转用余弦缓动,墙壁展开加过冲回弹,图标弹出带弹性
- 连接线和进度条 ------ 把各步骤串联成一条完整的流水线
核心代码
(1)3D 盒子的 DOM 结构
每个盒子由一个 scene(提供透视)> camera(固定视角)> box(3D 容器)三层嵌套,里面放 6 个面:
html
<div class="step-scene"> <!-- perspective: 600px -->
<div class="step-camera"> <!-- rotateX(-25deg) rotateY(30deg) 固定俯视角 -->
<div class="box"> <!-- transform-style: preserve-3d -->
<!-- 底面 floor -->
<!-- 前墙 front -->
<!-- 后墙 back → 盖子 lid 挂在这里 -->
<!-- 左墙 left -->
<!-- 右墙 right -->
<!-- 地面阴影 shadow -->
</div>
</div>
</div>
关键 CSS 就三行:
css
.step-scene {
perspective: 600px; /* 给子元素提供 3D 透视 */
}
.step-camera {
transform-style: preserve-3d; /* 保留子元素的 3D 变换 */
transform: rotateX(-25deg) rotateY(30deg); /* 固定的俯视角度 */
}
.box {
transform-style: preserve-3d; /* 盒子内部也要保持 3D */
}
perspective: 600px 决定了"镜头离盒子多远",值越小透视感越强。rotateX(-25deg) rotateY(30deg) 则给了一个略微俯视的等距视角,让盒子看起来立体感刚好 👀。
(2)用 JS 动态创建盒子的 6 个面
每个面的定位都靠 CSS transform 来"掰"到正确位置:
javascript
const SIZE = 50; // 盒子尺寸
function createBoxElement(size) {
const box = createElement("box");
// 底面 ------ 水平放倒
const floor = createFace(size);
floor.style.transform = "rotateX(90deg)";
box.appendChild(floor);
// 四面墙 ------ 各自旋转到位
const front = createWall(`translateZ(${size/2}px)`);
const back = createWall(`translateZ(${-size/2}px) rotateY(180deg)`);
const left = createWall(`translateX(${-size/2}px) rotateY(-90deg)`);
const right = createWall(`translateX(${size/2}px) rotateY(90deg)`);
// 盖子挂在背面墙上 ------ 铰链结构的关键!
const lid = createFace(size);
lid.style.transformOrigin = "center bottom"; // 旋转轴在底边
back.appendChild(lid);
return { el: box, floor, lid, walls: [front, back, left, right] };
}
这里最巧妙的是 back.appendChild(lid) ------ 盖子作为背面墙的子元素,旋转轴设在底边(transformOrigin: center bottom)。这样当我们用 rotateX 旋转盖子时,它就像真的被铰链连着一样翻开,而不是凭空旋转。
(3)三种缓动函数让动画有"质感"
这是整个效果的灵魂部分 🎨。同样是"从 A 到 B",不同的缓动曲线会带来完全不同的感觉:
盖子翻开 ------ 余弦缓动:
javascript
function lidEase(t) {
return (1 - Math.cos(t * Math.PI)) / 2;
}
开头慢、中间快、收尾慢,就像你掀盒子盖,先使点劲、中间自然甩开、最后缓缓到位 📦。
墙壁展开 ------ 立方缓出 + 正弦过冲:
javascript
const t = 1 - wallProgress;
const eased = 1 - t * t * t; // 立方缓出,越到后面越慢
const overshoot = eased + 0.08 * Math.sin(wallProgress * Math.PI); // 加一点回弹
const wallAngle = -90 * overshoot;
墙壁展开不是匀速"倒下去"的,而是快速展开后微微"弹过头"再回来。就像你用力掰开一个纸盒,边缘会回弹一下。那个 0.08 * Math.sin(...) 就是回弹的幅度,虽然只有 8%,但视觉上让动画一下子就"活"了 ✨。
图标弹出 ------ 三次方进出:
javascript
const popT = popRaw < 0.5
? 4 * popRaw * popRaw * popRaw
: 1 - Math.pow(-2 * popRaw + 2, 3) / 2;
图标从盒子里弹出来,先慢后快再慢,像从弹簧上弹起。弹出后还加了个微浮动效果:
javascript
const popFloat = progress >= 1 ? Math.sin(Date.now() * 0.003) * 2 : 0;
图标到位后不是僵硬地停住,而是微微上下浮动,就像悬浮在空中一样 🫧。
(4)流水线时间轴编排
四个盒子不是同时开的,而是依次触发,这就需要计算每个步骤的"出场时间":
javascript
const STEP_DURATION = 0.2; // 每个步骤占总时长的 20%
const gap = 0.05; // 步骤间的间隔
// 计算交错间距
const available = 1 - STEP_DURATION - gap * (n - 1);
const stagger = available / Math.max(n - 1, 1);
for (let i = 0; i < n; i++) {
const stepStart = i * (stagger + gap); // 每个步骤的起始时间点
const stepProgress = clamp((totalProgress - stepStart) / STEP_DURATION);
updateBox(steps[i], stepProgress, hue);
}
核心思想就是:把 0~1 的总进度切成几段,每段分配给一个步骤。stagger 控制步骤之间的间距------间距大了像排队,间距小了像一窝蜂。当前的配置让四个盒子依次触发,略有重叠,形成"流水线"的感觉 🏭。
(5)动态着色 ------ 颜色跟着进度走
每个盒子有不同的色相,而且颜色随开箱进度逐渐变化:
javascript
const hue = 210 + i * 30; // 四个盒子色相:210, 240, 270, 300
function styleFace(face, hue, alpha, progress) {
const sat = 40 + progress * 25; // 饱和度 40% → 65%
const light = 96 - progress * 12; // 亮度 96% → 84%
face.style.background = `hsla(${hue}, ${sat}%, ${light}%, ${0.92 * alpha})`;
}
刚开始盒子是浅浅的、几乎透明的,随着开箱逐渐变得饱和、实在。这种"渐显"的效果比一开始就实打实的好看很多 🎨。
(6)连接线填充
步骤之间的连接线不是突然出现的,而是从左向右"流过去"的:
javascript
if (i < n - 1 && steps[i].connectorFill) {
const connStart = stepStart + STEP_DURATION * 0.5; // 前一步完成一半时开始
const nextStart = (i + 1) * (stagger + gap); // 下一步开始时结束
const connProgress = clamp((totalProgress - connStart) / (nextStart - connStart));
steps[i].connectorFill.style.transform = `scaleX(${connProgress})`;
}
连接线在前一个盒子开到一半时就开始填充,到下一个盒子开始时恰好填满。时间卡得刚好,让整个流水线衔接得很顺畅 🔗。
逐步拆解
整理一下整个动画的层次关系:
arduino
wrapper
├── timeline(时间轴容器,flex 水平排列)
│ ├── step[0](connect)
│ │ ├── step-scene(透视容器,perspective: 600px)
│ │ │ └── step-camera(3D 视角)
│ │ │ ├── box(3D 盒子:floor + 4 walls + lid + shadow)
│ │ │ └── pop-icon(弹出图标 SVG)
│ │ ├── step-dot(状态圆点)
│ │ └── step-label(步骤名称)
│ ├── connector[0](连接线 + 填充动画)
│ ├── step[1](verify)
│ ├── connector[1]
│ ├── step[2](load)
│ ├── connector[2]
│ └── step[3](render)
├── progress-bar(总进度条)
└── progress-text(百分比文字)
动画循环机制也很简单,用 requestAnimationFrame + 取模:
javascript
const CYCLE = 8000; // 动画阶段 8 秒
const PAUSE = 3000; // 暂停阶段 3 秒
const TOTAL = 11000; // 总周期
function animate(now) {
requestAnimationFrame(animate);
const elapsed = now % TOTAL; // 取模 → 无缝循环
const totalProgress = Math.min(elapsed / CYCLE, 1); // 8 秒内 0→1,之后保持 1
// ...更新所有盒子和进度条
}
适用场景
这个 Loading 适合以下几种场景:
- 应用初始化:connect → verify → load → render 正好对应 "连接服务 → 鉴权 → 加载数据 → 渲染页面" 的真实流程
- 多步骤表单提交:每个盒子对应一个处理步骤,让用户知道"到哪一步了"
- 文件上传/处理:上传 → 校验 → 压缩 → 完成
源码地址
gitee

GitHub
源码地址:github.com/yongtaozhen...

🌟 觉得有帮助的可以点个 star~
🖊 有什么问题或错误可以指出,欢迎 pr~
📬 有什么想要实现的功能或想法可以联系我~
公众号
关注公众号『 前端也能这么有趣 』,获取更多有趣内容。
发送 加群 还可以加入群聊,一起来学习(摸鱼)吧~
说在后面
🎉 这里是 JYeontu,现在是一名前端工程师,有空会刷刷算法题,平时喜欢打羽毛球 🏸 ,平时也喜欢写些东西,既为自己记录 📋,也希望可以对大家有那么一丢丢的帮助,写的不好望多多谅解 🙇,写错的地方望指出,定会认真改进 😊,偶尔也会在自己的公众号『前端也能这么有趣』发一些比较有趣的文章,有兴趣的也可以关注下。在此谢谢大家的支持,我们下文再见 🙌。