☰
纯CSS实现环绕倒影加载动画:核心原理与完整代码实战
2026/10/18 12:57:31 网站建设 项目流程

加载动画是前端里最容易被忽略、但也最能体现细节的部分。最近我整理组件效果时,用纯 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、遮罩、反射这些高频场景全部过一遍,以后再处理粒子、进度、过渡类交互都会顺手很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询