加载动画是前端里最容易被忽略、但也最能体现细节的部分。最近我整理组件效果时,用纯 html+css 写了一个环绕倒影加载特效:中间一圈发光进度环,周围几颗光点像行星一样公转,容器底部还有一层渐隐倒影,整体看起来像把一个小装置放在深色玻璃板上。这个效果适合做页面请求等待、后台任务处理、数据初始化这类场景;前端初学者可以通过它把 CSS 动画里的 transform、animation、遮罩和反射一次摸透,需要做业务页面的同学也可以直接抽成一个通用加载组件。接下来我把实现思路、核心原理和完整代码一次讲清楚,包括我调了挺久的几个坑。
1. 先拆效果:这个加载特效包含哪几层视觉结构
写代码前一定要先学会拆结构。很多人拿到一个动效就急着写,结果代码缠成一团,后面想改颜色、改速度都很痛苦。这个“环绕倒影加载特效”看着花,拆开其实只有三个部分:中心展示区、环绕轨道区、底部倒影层。
1.1 中心区:进度环与百分比数字
中心区是整个 loading 的视觉焦点,我放了一个 conic-gradient 画出来的圆环,外圈带浅色底环,中间用 mask 挖掉中心,形成环形进度条。旁边叠一个百分比数字,用来展示加载进度。
这个数字不是摆设。用户等待加载时,最想知道的是“还要等多久”,一个明确的百分比能大幅降低焦虑感。如果只是装饰性 loading,不放数字也可以,放 Logo 或图标会更干净;但如果要接真实进度数据,中心区放数字是最稳妥的方案。
圆环之所以用 conic-gradient 而不是 border 画,是因为 border 只能做静态整圆,做不出“缺口进度”的视觉。conic-gradient 可以指定起止角度,比如0deg到270deg表示已加载 75%,剩下的 90 度用浅色填补,配合 mask 挖空中心,视觉上就是一段圆环进度。mask 的原理可以理解成“印章”:透明的部分不显示,实色的部分显示,最终只保留一段 8~10px 宽的圆环。
1.2 环绕轨道:等距分布的发光小点
环绕区我用 6 个小圆点,围绕中心做等距公转。为什么是 6 个而不是 3 个或 12 个?
这个取舍是有讲究的。数量越少,用户越容易关注单个点的运动,适合极简风;数量越多,越有“星光粒子”的密集感,但渲染压力也越大。6 个正好处于平衡点,而且 360 度除以 6 等于 60 度,天然形成六边形对称,画面稳定不杂乱。每个小圆点我给了一个径向渐变和一道发光阴影,看起来像一颗颗小行星。
这 6 个点并不是直接用 JS 去计算坐标的,而是靠 CSS 自身去“公转”。每个点都有自己的轨道容器,轨道容器负责旋转,点本身只负责待在初始位置上。通过负值 animation-delay 把 6 个点的相位错开,页面一打开就能看到完整的 6 点均匀分布,不需要等第一圈转完。
1.3 倒影层:让静态容器瞬间有高级感
倒影是这个特效里最提升质感的部分。同样的旋转小球,没有倒影就是一个普通点阵,加了倒影之后像摆在镜面或玻璃板上,视觉层次一下强了很多。
倒影在 CSS 里最省事的做法是-webkit-box-reflect,一行属性就能在元素下方生成镜像,还支持用渐变控制渐隐范围。但它的兼容性有明确边界:Chromium 系浏览器基本都能用,Firefox 不支持。所以我把倒影定位成“渐进增强”——核心的环绕加载效果不依赖它,支持的浏览器看到更丰富的一层,不支持的浏览器至少不影响功能和整体观感。
2. 纯CSS做“公转+倒影”,核心原理就这三个
要真正把这个效果玩明白,光复制代码不够,得理解背后三个原理:如何让元素绕圈、如何生成倒影、如何控制加载节奏。
2.1 圆点为什么能绕圈:rotate 与 translateX 的配合
很多人看到“圆周运动”就觉得一定要上 JS 每帧计算坐标,其实纯 CSS 就能搞定。关键思路是:先把元素放到圆周上,再让它的父级旋转。
具体做法是:准备一个零尺寸的轨道容器,把它定位在舞台正中心,相当于一个“圆心钉点”。圆点小球默认也放在这个圆心上,然后给小球一个transform: translateX(96px),把它沿水平方向推出去 96 像素,也就是放在半径为 96px 的圆周上。接下来让轨道容器持续rotate(360deg),小球就跟着整个坐标系绕圆心公转。
为什么不用left和top去实现移动?因为 left/top 动画会触发布局和重绘,性能明显差;transform 只发生在合成层,GPU 参与渲染,动画更流畅。这也是 CSS 动画设计的基本原则:能用 transform 和 opacity 的时候,坚决不要动 left/top/width/height。
多个小球等距分布,本质是给每个小球独立的轨道容器,再把它们的动画开始时间错开。比如 6 个小球,公转周期 3 秒,那每个小球之间的时间差就是 0.5 秒。这里有个关键技巧:使用负值animation-delay。负延迟的意思是“动画已经跑到中途了”,页面刚打开时就能直接看到完整的等分图案,不会有一段空白等待期。
2.2 倒影不是复制元素:-webkit-box-reflect 的工作方式
倒影的实现,我首选-webkit-box-reflect,不是因为它能跨浏览器,而是因为它零成本、零维护。它的语法是这样:
.stage { -webkit-box-reflect: below 12px linear-gradient(transparent 50%, rgba(0, 0, 0, 0.35)); }below表示往下反射,12px是反射内容与原内容之间的距离,后面的linear-gradient是作用于反射副本上的遮罩。遮罩里transparent 50%让反射图的上半部分完全不可见,rgba(0,0,0,0.35)则让底部稍微变暗,这样镜像就不是一个生硬的完整副本,而是带着渐隐效果、自然融入背景。
需要强调一点:这个属性只对 WebKit 内核和大部分 Chromium 系浏览器生效,Firefox 会直接忽略。跨浏览器项目里我一般把它当作增强项,而不是必选项。如果产品经理非要所有浏览器完全一致,只能做 DOM 镜像方案:把同一段结构再复制一份,用scaleY(-1)上下翻转,再叠加mask-image做渐隐。这个方案通用但冗余,我很少用,因为 loading 特效本来就是短暂存在的,为了一个装饰效果去维护两份 DOM 结构,性价比太低。
2.3 加载动画的节奏感:转速、相位差与进度反馈
动画不是越快越好,loading 动效尤其讲究“稳定感”。我实测下来,公转一圈 2.4 秒到 3.6 秒是比较自然的区间。1 秒一圈会让人感觉程序失控,好像在疯狂空转;5 秒一圈又容易让用户怀疑页面卡死了。把轨道公转周期设为 3 秒,中心圆环反向转 2.4 秒,两组运动速度不同,会形成一种微妙的层次感。
中心圆环的旋转方向我还特意和轨道反着来:轨道左转,圆环右转。两个方向相反的运动同时出现,视觉上会有一个“拉扯感”,比所有元素同方向旋转更耐看,也更能表达“系统正在运转”的意思。
如果中心数字代表真实进度,数字的更新频率也要讲究。真实进度可以用 XHR 的onprogress事件或浏览器原生加载事件去拿,而不是用 setInterval 随机跳。即使是纯装饰动效,数字跳动的节奏也尽量不要完全规律,否则用户很容易察觉这是假的,对业务场景来说反而是减分项。
3. 完整代码实操:拿到就能改着玩的加载特效
原理讲完,直接上可以复制的完整实现。这个版本我整理成了可直接运行的 HTML 文件,样式去掉了外部依赖。
3.1 HTML 骨架:把轨道和中心区分开
HTML 结构非常简单,重点是分层。容器.stage负责整体定位和倒影,里面先放 6 个独立轨道,再放中心区。轨道和中心区必须是兄弟节点,不能嵌套。嵌套的后果是中心数字会跟着轨道旋转,文字最后倒过来,这种错误我见过很多次。
<div class="loader"> <div class="stage"> <div class="orbit o1"><i></i></div> <div class="orbit o2"><i></i></div> <div class="orbit o3"><i></i></div> <div class="orbit o4"><i></i></div> <div class="orbit o5"><i></i></div> <div class="orbit o6"><i></i></div> <div class="center"> <div class="ring"></div> <span class="num">0%</span> </div> </div> </div>每个.orbit都是一个“零尺寸轨道指针”,它的作用只有一个:定位在圆心,然后带动自己的子元素i旋转。i是实际发光的小球。中心区的.ring是圆环进度条,.num显示百分比数字。
3.2 核心 CSS:圆环、公转、倒影一次到位
CSS 是核心部分。我用了少量 CSS 变量方便后续调参,代码里都写了注释。
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: radial-gradient(circle at 50% 30%, #1a2433 0%, #0b0f16 70%); } .loader { position: relative; padding-bottom: 40px; } .stage { --radius: 96px; --duration: 3s; --size: 14px; --color: #4facfe; position: relative; width: 220px; height: 220px; -webkit-box-reflect: below 12px linear-gradient(transparent 55%, rgba(0, 0, 0, 0.35)); } /* 轨道容器:钉在圆心,自身旋转带动小球公转 */ .orbit { position: absolute; left: 50%; top: 50%; width: 0; height: 0; animation: spin var(--duration) linear infinite; } .orbit i { position: absolute; left: 0; top: 0; width: var(--size); height: var(--size); border-radius: 50%; background: radial-gradient(circle at 35% 35%, #d7f1ff, #4facfe 60%, #1d6fa8); box-shadow: 0 0 10px rgba(79, 172, 254, 0.9); /* 先居中,再沿X轴推出半径距离 */ transform: translate(-50%, -50%) translateX(var(--radius)); } /* 6个小球按周期等分相位,负值delay让动画开局即完整图案 */ .o1 { animation-delay: 0s; } .o2 { animation-delay: -0.5s; } .o3 { animation-delay: -1s; } .o4 { animation-delay: -1.5s; } .o5 { animation-delay: -2s; } .o6 { animation-delay: -2.5s; } /* 中心区:圆环 + 数字 */ .center { position: absolute; left: 50%; top: 50%; width: 120px; height: 120px; transform: translate(-50%, -50%); z-index: 2; } .ring { position: absolute; inset: 0; border-radius: 50%; background: conic-gradient( from 0deg, #4facfe 0deg, #4facfe 270deg, rgba(255, 255, 255, 0.18) 270deg, rgba(255, 255, 255, 0.18) 360deg ); -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 9px), #000 calc(100% - 8px)); mask: radial-gradient(farthest-side, transparent calc(100% - 9px), #000 calc(100% - 8px)); animation: spin 2.4s linear infinite reverse; } .num { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 24px; font-family: "Segoe UI", Arial, sans-serif; letter-spacing: 1px; text-shadow: 0 0 8px rgba(79, 172, 254, 0.8); } @keyframes spin { to { transform: rotate(360deg); } }这里有几个细节要注意:
第一,小球定位必须用left: 0; top: 0;配合transform: translate(-50%, -50%) translateX(半径)。先translate(-50%, -50%)是把小球中心对齐到圆心钉点,再translateX(96px)才是从圆心向外推出半径距离。如果漏掉第一步,小球会以左上角为基准偏移,绕圈轨迹会歪。
第二,mask的farthest-side是相对圆心的最远边,也就是圆环外边界。transparent calc(100% - 9px)表示距离外边界 9px 以内的区域透明,#000 calc(100% - 8px)表示再往内 1px 变成实心,最终保留下来的就是一条粗细约 1px 的极细圆环边缘。这里如果你想要更粗的进度环,只需要调整两个 stop 之间的距离,比如改成transparent calc(100% - 14px)和#000 calc(100% - 12px),就能得到更宽的环。
第三,-webkit-box-reflect是在.stage上生效的。它会把整个 stage 区域实时镜像到下方 12px 处,包括旋转的小球和中心数字,而且镜像会跟着动画实时更新,这就是纯 CSS 反射的优势,不需要额外写任何同步逻辑。
3.3 调参心得:半径、速度、颜色、倒影怎么微调
这个特效的观感对参数非常敏感,我整理了一张调参对照表,方便你不至于改一下数值就翻车。
| 参数 | 位置 | 效果表现 | 我的建议值 |
|---|---|---|---|
--radius | .stage变量 | 小球绕圈半径。太小显拥挤,太大容易溢出容器边界 | 容器宽 220px 时用 90~100px |
--duration | .stage变量 | 小球公转一圈时间。小于 2s 显得急躁,大于 4s 显得卡顿 | 2.6s~3.4s |
--size | .stage变量 | 小球尺寸。太大挡住中心数字,太小发光效果起不来 | 12~16px |
--color | .orbit i背景色 | 主色调。建议和品牌色统一 | 蓝紫色系不易翻车 |
| 倒影偏移量 | .stage的below后数值 | 倒影与原内容间距。太小看不出反射,太大像悬浮复制品 | 10~16px |
| 倒影遮罩透明度 | linear-gradient里 | 控制渐变程度。太黑会盖住背景,太透则倒影不明显 | 0.3~0.4 |
调参的时候,核心思路是“先整体,后细节”。先把半径和周期定好,再调中心圆环尺寸,最后才微调发光和倒影强度。如果直接一上来就调 box-shadow,很容易迷失在细节里,整体比例早就偏了。
另外,我把常用数值都做成了 CSS 变量,实际修改时可以只改变量,不必在代码里到处找散落的数值。比如想让点更小更精致,改--size: 12px就完事,所有小球会同步更新。
3.4 接入真实页面:进度联动与移除加载状态
代码写好后,接入业务页面时还要考虑加载结束的过渡处理。直接display: none会让元素瞬间消失,很生硬。更好的方式是先让 loading 透明度过渡到 0,再移除。
.loader { transition: opacity 0.4s ease; pointer-events: none; } .loader.done { opacity: 0; }JS 部分可以这样模拟进度更新:
const num = document.querySelector('.num'); const loader = document.querySelector('.loader'); let p = 0; const timer = setInterval(() => { p = Math.min(100, p + Math.floor(Math.random() * 6) + 3); num.textContent = p + '%'; if (p >= 100) { clearInterval(timer); loader.classList.add('done'); setTimeout(() => loader.remove(), 500); } }, 320);这个片段适合演示。真实业务里不要用随机数,优先用 XHR 的onprogress事件或者资源加载的真实字节数去更新。如果拿不到真实进度,宁可做成无数字的纯旋转动画,也不要做个“假数字”给用户看。用户一旦发现数字在乱跳,对产品信任度影响很大。
4. 踩坑实录:这些细节能帮你少走一晚弯路
这部分是我实际操作中真正踩过的坑,每一个都花了不少时间排查。
4.1 倒影不显示,不是你写错了而是浏览器不支持
-webkit-box-reflect最大的坑就是 Firefox。我第一次写完,在 Chrome 里看效果正常,切到 Firefox 打开,倒影直接消失,小球光秃秃地在转。一开始还以为是代码写错,后来检查才发现是浏览器兼容性问题。
处理方法分两层:如果只是个人项目或内部系统,我建议直接把倒影当作增强,核心 loading 不依赖它,Firefox 用户看到没有倒影的版本也一样能用;如果产品明确要求所有浏览器一致,那就只能做镜像 DOM 方案,复制一份.stage结构,用transform: scaleY(-1)翻转,再叠一层mask-image渐变。这个方案通用,但代码量较大,而且两个副本要保持一致的动画状态,在部分复杂场景下会多出不少维护成本。
个人经验是:除非用户强烈要求,否则不要为了一个装饰性倒影去维护双份 DOM。渐进增强在动效这里完全适用。
4.2 绕圈变成了椭圆,先检查这三个地方
公转轨迹不正圆,是这类 CSS 动画最常遇到的问题。排查时按下面三个原因逐个检查:
第一,舞台容器是不是正方形。.stage如果宽高不一样,圆周运动在横向和纵向的视觉比例就不同,看起来就是椭圆。保证容器是正方形,问题先排除一半。
第二,轨道容器是否真的在圆心。轨道容器用的是left: 50%; top: 50%; width: 0; height: 0;,所以它的中心点正好是舞台中心。如果你改成margin: auto之类的方式去居中,因为绝对定位和 flex 布局的对齐逻辑不同,很可能得到的是偏离中心的位置,圆就歪了。
第三,小球自身有没有居中。小球的transform: translate(-50%, -50%)这一步是拿它的自身尺寸去偏移,只要这一句在translateX(半径)前面,小球中心就会先对齐圆心,再被推到圆周上。如果顺序反了,或漏掉了负 margin,小球会以自身左上角为准,轨迹自然变成乱七八糟的椭圆或偏移圆。
4.3 动画卡顿、首帧空窗和动效体验问题
关于卡顿,我先说结论:这个特效的渲染压力主要来自 box-shadow,而不是小球本身。6 个小球每人带一道 10px 范围的发光阴影,在普通屏幕上没有问题,但在低端移动设备上如果还要再叠加倒影,性能会明显下降。优化手段是控制小球数量、缩小发光范围、在同屏同时出现多个 loading 时禁用阴影。
首帧空窗是另一个容易忽略的问题。如果你给 6 个小球设置的是正数animation-delay,页面首次打开时,最先出现的只是第一个小球,要等到第一个周期跑完,6 个点才能凑齐完整图案。这个空窗期视觉上特别明显。用负值animation-delay就能解决,动画会直接从中途相位开始,页面一打开就是完整六边形。
还有一个体验细节:对喜欢减弱动画效果的用户,可以加一段prefers-reduced-motion媒体查询,把动画关闭或降速。这个虽然是可选项,但加了对特殊人群更友好,规范上也更完善。
4.4 常见问题速查表
| 现象 | 可能原因 | 处理方法 |
|---|---|---|
| 倒影不显示 | 浏览器不支持-webkit-box-reflect | 使用 DOM 镜像方案,或接受无倒影版本 |
| 小球不绕圈 | 轨道容器没有animation,或 keyframes 里没写transform: rotate | 检查.orbit是否绑定spin动画 |
| 绕圈轨迹是椭圆 | 容器不是正方形 / 轨道不在圆心 / 小球未居中 | 检查宽高、定位、transform 顺序 |
| 页面打开时图形不完整 | animation-delay 使用了正值 | 改成负值 delay |
| 中心数字跟着旋转 | .center嵌套在了.orbit内部 | 把中心区移出轨道容器,做成兄弟节点 |
| 动画卡顿掉帧 | box-shadow 或 filter 过多 | 减少数量,收起阴影,或使用will-change: transform |
| Firefox 下视觉变弱 | Firefox 不支持部分 webkit 属性 | 把倒影当增强,不强求一致性 |
5. 一个效果能玩出多少种变体:扩展思路
基础版本做完之后,这个结构可以很容易扩展出不同玩法。我在实际做组件库的时候,经常把这种 loading 改成各种形态去适配不同场景。
5.1 内外双圈反向轨道,快速增加信息层次
想让效果更丰富,最简单的方式是加一组内圈轨道,半径缩小,方向反向。视觉上形成内外两层反向旋转的星系感。核心代码只需要在原本轨道基础上加一个类:
.orbit-inner i { transform: translate(-50%, -50%) translateX(58px); background: radial-gradient(circle at 35% 35%, #ffe6a7, #ffd166 60%, #c9761e); box-shadow: 0 0 8px rgba(255, 209, 102, 0.9); } .orbit-inner { animation-direction: reverse; animation-duration: 2.2s; }两组轨道的半径和周期不一样时,组合图案会有一个较长的重复周期,观感上比单一旋转更丰富。轨道数量不要加太多,两圈、总共 8 到 10 个小球已经是视觉上限了,再多就乱。
5.2 把圆点换成文字:反向补偿旋转是关键
如果把轨道上的小球替换成文字,比如“加载中”“努力加载”几个字,需要额外处理一个关键问题:文字会跟着轨道旋转,最后变成倒着的。解决办法是给文字再加一层周期相同、方向相反的反向旋转动画。
<div class="orbit"> <i><span>载</span></i> </div>.orbit i span { display: block; animation: spin var(--duration, 3s) linear infinite reverse; color: #fff; font-size: 14px; }注意,反向旋转的动画要作用在文字的内层<span>上,不能直接作用在i上,否则会把translateX(半径)的定位也抵消掉,文字会从圆周上掉回圆心。这个细节调试时很容易翻车,我建议先用单字测试反向逻辑,再上完整的句子。
5.3 让加载效果真正接上业务进度
前面给的 JS 是模拟进度,真实项目里建议把 loading 封装成独立组件,对外暴露一个更新进度的方法。内部实现可以是请求驱动的:文件上传用上传进度事件,接口数据加载用 fetch 的流式读取进度,图片预加载用onload计数。这样 loading 显示的数字就是从真实业务流程里拿到的,不会骗用户。
我之前做过一个模拟项目,用图片预加载来做进度:预先拿到图片总数量,每加载完成一张更新百分比。这样做出来数字变化和实际感受完全吻合,用户不会觉得进度条在乱跳。如果业务场景实在拿不到真实进度,那就干脆隐藏数字,只保留旋转动画,让用户只知道“系统在跑”就够了。
我个人做这类特效的习惯是:先用浅色背景把所有元素边界、宽高、transform 都调清楚,再上深色背景和发光阴影,最后才加倒影。倒影会成倍放大视觉错觉,如果一开始就开着倒影调位置,很多错位问题会被视觉欺骗掩盖。这个环绕倒影特效本身不复杂,但把它当成一个 CSS 动画的“练手标本”去理解,你可以把 transform、animation-delay、遮罩、反射这些高频场景全部过一遍,以后再处理粒子、进度、过渡类交互都会顺手很多。