☰
纯CSS加载光效实战:骨架屏扫光与按钮Loading
2026/10/21 3:37:57 网站建设 项目流程

你有没有遇到过这种页面:接口一慢,整屏灰块铺开,光线干巴巴地闪,用户以为系统崩了。实际上CSS加载光效能做得比这优雅得多。这次我做某后台系统首屏优化时,把“加载光效”这件事拆了个底朝天——从骨架屏扫光、按钮环形loading到深浅色适配和系统减动效,全部纯CSS实现,不引JavaScript库,也不切GIF。这篇文章记录的是完整思路和可直接照抄的写法,适合正在做加载体验优化的前端同学参考。

1. 加载光效的路线选择:纯CSS方案到底强在哪

1.1 静态灰块和动态光效,差的不是“动”而是“感知”

先说清楚一个概念:CSS加载光效通常出现在两个场景。一个是骨架屏(Skeleton Screen)里的扫光效果,也就是英文社区常说的Shimmer;另一个是按钮、卡片、页面中央转圈的Spinner。两者的目标其实是同一个——告诉用户“系统还在工作”。

最朴素的方案是静态灰块。我之前负责的某后台系统,首屏要拉一堆汇总数据,接口响应大概1到2秒。页面在数据返回前就是一块块灰底占位,测试同事反馈说“像卡死了”。我把骨架块换成带扫光的效果后,同一个接口、同一个响应速度,用户很少再反复刷新页面。这不是心理安慰,人的视觉系统对静止画面会很快失去注意力,然后产生“页面是不是死掉了”的怀疑。而一道缓慢扫过的光会持续传递“画面是活的”的信号,等待的主观感受会明显缩短——这对后台系统、数据大屏这类等待时间无法消除的场景尤其关键。

1.2 GIF、JS动画库和CSS的取舍:我为什么放弃其它方案

在做标记前,我把能想到的实现方案都列了一遍。这里面的坑不是技术做不到,而是“为了不让用户等太久,结果引入了一个加重加载负担的方案”。

方案优势致命问题我的结论
GIF / WebP动图制作简单、兼容好体积大,尤其透明动画;不会跟随主题色变化,放大还发虚优化加载却引入图片请求,方向反了
APNG动画支持透明、循环文件体积比GIF还夸张,部分场景兼容性一般不划算
JS动画库灵活、回调多、控制力强增加脚本体积,还要考虑框架集成成本为了一个装饰性效果引库,重了
纯CSS零依赖、体积接近零、CSS变量天然适配主题交互控制简单,但对加载光效而言完全够用最终采用

加载光效本质上是“过渡态装饰”,最理想的状态就是不发新请求。纯CSS方案不动网络资源,不引入运行时依赖,几行声明就解决问题。尤其现在全平台都支持CSS变量、prefers-color-scheme和prefers-reduced-motion,做深浅色适配和减动效适配都很方便。它不是“退而求其次”,而是这类场景的合理选择。

2. 骨架屏扫光的两种主流写法:从background-position到transform

2.1 线性渐变与背景位置:让“一束光”在灰块上扫过

扫光效果的原理一句话能说清:在灰色占位块的背景里铺一张比容器更宽的渐变图,其中有一段颜色明显更亮,然后通过某种方式让这段亮色在容器里移动。

最简单、代码最少的版本是用background-position配合background-size:

.skeleton-line { height: 16px; border-radius: 8px; background-color: #e9ecf1; background-image: linear-gradient( 90deg, transparent 0%, rgba(255, 255, 255, 0.6) 50%, transparent 100% ); background-size: 200% 100%; background-repeat: no-repeat; animation: shimmer 1.6s ease-in-out infinite; } @keyframes shimmer { 0% { background-position: 100% 0; } 100% { background-position: 0% 0; } }

关键点在于:background-size: 200% 100%把背景图的宽度撑成了容器宽度的两倍,容器只能看到背景图的一半。background-position: 100% 0时显示的是背景图右半段,background-position: 0% 0时显示的是左半段。把动画方向设置成从100%到0%,视觉上高光就是从左往右扫过去。

这里有个常见的翻车点:很多人把动画方向反过来写,结果光是从右往左走的,看上去总是怪怪的。如果你发现扫光方向不对,优先检查@keyframes里的起点和终点是否写反了。

2.2 伪元素+translateX:更好控制性能的升级版

background-position写法足够简单,但它有一个隐患:背景位置变化在部分情况下会产生重绘,页面骨架块一多,低端设备可能掉帧。我更喜欢在大规模骨架屏场景用伪元素加transform的方案:

.skeleton { position: relative; overflow: hidden; background-color: var(--skeleton-base); } .skeleton::after { content: ""; position: absolute; inset: 0; transform: translateX(-100%); background-image: linear-gradient( 100deg, transparent 20%, rgba(255, 255, 255, 0.6) 50%, transparent 80% ); animation: shimmer 1.6s ease-in-out infinite; } @keyframes shimmer { 0% { transform: translateX(-100%); } 100% { transform: translateX(100%); } }

思路是给每个骨架块加一个覆盖整个块的伪元素,通过translateX把它从左边移到右边。因为transform动画只触发合成层,不会反复触发重绘,性能和流畅度都会更好。代价是需要给每个骨架块设置position: relative和overflow: hidden。

2.3 深浅色模式下,扫光参数的调整套路

加载光效最容易露馅的地方是深浅色模式:很多人一套样式走天下,深色模式下浅色背景的扫光要么亮得刺眼,要么完全看不出来。我用CSS变量把底色和高光分开管理:

:root { --skeleton-base: #e9ecf1; --skeleton-glow: rgba(255, 255, 255, 0.6); } @media (prefers-color-scheme: dark) { :root { --skeleton-base: #26292e; --skeleton-glow: rgba(255, 255, 255, 0.12); } }

浅色模式下,高光用半透明白,叠加在浅灰底上形成自然的高光感;深色模式下,如果还用半透明白,会把骷髅块扫得像探照灯。压到0.1左右,既有光扫过的感知,又不至于闪瞎眼。这两个值没有绝对标准,建议配合你们自己的设计变量多调几次。

骨架块的圆角也要跟着写:扫光是一个矩形渐变,如果块本身是圆角,一定要加border-radius并让伪元素继承,否则光扫到圆角处会硬生生切一刀,特别难看。

3. 按钮加载态的环形光效:从border圆环到渐变光弧

3.1 最简单的border透明环:一条边转起来就是光

按钮提交时最常用的加载指示器,是那个转圈的圆环。最基础的做法是用border的四条边做文章:

.spinner { width: 20px; height: 20px; border: 2px solid transparent; border-top-color: currentColor; border-radius: 50%; animation: spin 0.8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } }

transparent让三条边隐形,只有顶部一条有颜色;旋转起来之后,这条彩色边就像在圆环里扫出一道残影。用currentColor可以让圆环自动跟随按钮文字颜色,按钮在深浅色主题下切换时不用额外写颜色。

这个写法虽然简单,但弧光的“尾巴”很短,本质上就是一条硬边在转,高级感有限。如果产品对视觉要求高,可以看下面这个进阶版。

3.2 conic-gradient渐变光弧:让loading更有“光”的味道

想要更柔和、更有光效的旋转弧线,可以用conic-gradient生成一个自带渐隐尾迹的圆环:

.loader { width: 40px; height: 40px; border-radius: 50%; background: conic-gradient( from 0deg, transparent 0deg, transparent 240deg, #4f8cff 300deg, transparent 360deg ); -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 4px), #000 calc(100% - 4px)); mask: radial-gradient(farthest-side, transparent calc(100% - 4px), #000 calc(100% - 4px)); animation: spin 1.1s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } }

conic-gradient从0度开始,前面一大段是透明的,到240度附近开始出现颜色,到300度达到最亮,然后又迅速消失。这样形成的不是均匀的圆环,而是一段有头有尾的光弧,旋转时看起来更像光束在追着跑。

mask的作用是把中心挖空,只保留外圈4像素左右的圆环。有了mask,调整圆环宽度不需要改元素尺寸,改calc(100% - 4px)里的4px就行。

3.3 转速、配色和按钮布局的细节

光效最终要融入页面,不能单独好看。我一般按这三条标准来:

第一,转速。大圆环Loading建议1.1s到1.6s转一圈,太快的旋转会让人紧张,太慢又让人怀疑请求卡了;按钮里的小圆环因为尺寸小,0.8s到1s比较灵动。多个Loading指示器同时出现时,尽量统一周期,不要一个快一个慢,节奏乱会显得很廉价。

第二,配色。光弧颜色跟着品牌主色走。浅色背景下用饱和度正常的品牌色;深色背景下把颜色提亮一档,不然暗色环境里的饱和度会显得发脏。按钮里的Spinner一般直接用按钮文字颜色,不要单独配一版颜色,否则深浅色模式下还要想尽办法维护两套值。

第三,按钮布局。按钮进入加载态时,最常见的布局是inline-flex加gap,左边放Spinner,右边文字改成“加载中…”。不要忽略按钮宽度跳动问题:原来文字是“提交”两个字,变成“加载中…”之后宽度变了,整个按钮会左右晃动。稳妥做法是给按钮一个min-width,或者预先用隐藏的占位文本撑住宽度。

<button class="btn" aria-busy="true"> <span class="spinner" aria-hidden="true"></span> 加载中… </button>
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-width: 96px; }

给按钮加上aria-busy="true",再把Spinner用aria-hidden="true"隐藏,读屏用户能感知到“这个按钮正在忙”,视觉用户也能看到光效,两种体验都照顾到了。

4. 光效落地中最容易翻车的性能与体验细节

4.1 background-position动画为什么在大量骨架块时会卡

骨架屏往往是成片出现的:每个卡片里可能有头像、标题、两行文本,一屏几十个骨架块很常见。我在某个模拟项目里复现过这个问题——二十个骨架块同时跑background-position扫光,用性能面板记录,低端模式下出现了肉眼可感知的掉帧。

原因不复杂:background-position变化在浏览器的渲染流程里属于绘制阶段,动画的每一帧都要重新绘制背景;而transform变化只需要合成器把已有的层做位移,主线程的压力小很多。页面只有三五个骨架块时,background-position写法完全可以;一旦骨架块成片出现,优先切到伪元素+transform方案。这个选择比纠结动画曲线重要得多。

另外补充一句:不要把扫光动画加到有大量文字真实渲染的区域。骨架屏结束后文字内容是真实DOM,扫光伪元素只存在于骨架屏容器的子项里,数据返回后整个骨架容器应该一并移除,否则覆盖在真实内容上会一直闪。

4.2 prefers-reduced-motion不是可选项,是基本盘

动效不是所有人都享受,前庭障碍用户看到持续扫光可能头晕。prefers-reduced-motion媒体查询不是加分项,是现代前端的基本盘:

@media (prefers-reduced-motion: reduce) { .skeleton::after, .spinner, .loader { animation: none; } .skeleton::after { display: none; } }

用户系统开了“减少动态效果”时,扫光和旋转直接关掉。骨架屏保留静态灰块,按钮保留小圆环但静止,功能不受影响,只是少了装饰性动效。我见过一些团队因为“进度指示器也是功能的一部分”而保留旋转,这个观点能理解,但实际体验中,静态的“加载中”文字配合灰块足够表达状态了。

4.3 骨架屏显示时机和请求超时兜底

加载光效有一个隐藏陷阱:如果接口只花100毫秒就返回了,骨架屏闪一下马上消失,视觉上比直接显示内容还难受。我习惯加一个延迟显示逻辑:请求发出后过200到300毫秒才挂上骨架屏,让系统判断“这个请求短时间内回不来”,再去展示加载态。

const timer = setTimeout(() => { page.classList.add('loading'); }, 250); try { const data = await fetchData(); render(data); } finally { clearTimeout(timer); page.classList.remove('loading'); }

签名还有超时兜底。光效再好看,也不能让用户永远等。一般给请求设置10秒左右的超时,超时后收起骨架屏并显示错误重试区域。这样光效才是“安抚工具”,而不是“掩盖故障的工具”。

5. 一份可直接复制的完整示例和我的实测结论

5.1 页面级骨架扫光的完整HTML/CSS

把所有经验凑到一起,一个完整的页面骨架扫光大概长这样。HTML可以简单分成头像、标题、文本行三个组件:

<div class="page"> <div class="profile-skeleton"> <div class="avatar"></div> <div class="profile-lines"> <div class="line line-title"></div> <div class="line line-subtitle"></div> </div> </div> <div class="list-skeleton"> <div class="card"> <div class="line line-full"></div> <div class="line line-short"></div> </div> <div class="card"> <div class="line line-full"></div> <div class="line line-short"></div> </div> </div> </div>

对应的CSS:

:root { --skeleton-base: #e9ecf1; --skeleton-glow: rgba(255, 255, 255, 0.6); --skeleton-radius: 8px; } @media (prefers-color-scheme: dark) { :root { --skeleton-base: #26292e; --skeleton-glow: rgba(255, 255, 255, 0.12); } } .skeleton, .avatar, .line { position: relative; overflow: hidden; background-color: var(--skeleton-base); border-radius: var(--skeleton-radius); } .skeleton::after, .avatar::after, .line::after { content: ""; position: absolute; inset: 0; transform: translateX(-100%); background-image: linear-gradient( 100deg, transparent 20%, var(--skeleton-glow) 50%, transparent 80% ); animation: shimmer 1.6s ease-in-out infinite; } .avatar { width: 48px; height: 48px; border-radius: 50%; } .line { height: 16px; } .line-title { width: 60%; height: 20px; } .line-subtitle { width: 40%; height: 12px; } .line-full { width: 100%; margin-bottom: 12px; } .line-short { width: 80%; margin-bottom: 4px; } .profile-skeleton { display: flex; gap: 12px; align-items: center; margin-bottom: 32px; } .profile-lines { flex: 1; display: grid; gap: 10px; } .card { background: transparent; border: 1px solid rgba(127, 127, 127, 0.1); border-radius: 12px; padding: 16px; margin-bottom: 12px; } @keyframes shimmer { 0% { transform: translateX(-100%); } 100% { transform: translateX(100%); } } @media (prefers-reduced-motion: reduce) { .skeleton::after, .avatar::after, .line::after { animation: none; display: none; } }

使用时给真正处于加载态的容器加上骨架屏,数据返回后整体移除或隐藏即可。因为骨架块的background-color用CSS变量控制,深浅色模式、品牌色的调整都集中在一个位置,维护成本很低。

5.2 体积、帧率与维护成本:我实际测到的情况

我把这个方案放进测试页里和GIF对比过。一个简单扫光GIF在保证清晰度的前提下至少要8到12KB,而上面这套骨架屏全部压缩后的CSS大约1KB不到,还不需要额外网络请求。帧率方面,二十个骨架块同时扫光,transform方案在低端模拟环境下能稳定跑满60帧,background-position版本则出现过掉到40多帧的情况。差别不一定每次都那么明显,但骨架屏本来就是“雪中送炭”的组件,性能余量越足越稳。

维护成本也值得一提:GIF或APNG动画一旦需要改颜色、改速度、改方向,就得重新导出图片,一套深浅色要准备两套甚至多套素材;CSS方案改一个变量、调一个动画时长就全局生效,后续迭代省心非常多。

5.3 还能怎么玩:CSS变量控制节奏、颜色和强弱

扫光效果其实可以做得更聪明。把动画时长也提成CSS变量,运行时根据网络情况动态调整:

.skeleton::after, .avatar::after, .line::after { animation-duration: var(--shimmer-duration, 1.6s); }

在网络明显变慢时,通过JavaScript往根元素上设置一个更慢的时长,让光扫得更从容,配合“用户等待时间被拉长”的感知;网络恢复时再调回正常值。这个做法不需要改动画结构,只动一个变量。

再到后期,你还可以把扫光颜色换成品牌色的弱化版,或者给光效加一点透明的渐变方向,配合不同的卡片形态做出“水波扫过”的感觉。所有扩展都是基于同一套骨架结构,核心价值始终是:用最小的成本,让等待状态不再冷冰冰。

我在实际项目里最终保留的是伪元素+transform扫光,动画时长固定1.6秒;按钮里的Spinner用最朴素的border透明环。光效这个东西,最大的作用不是好看,而是让用户知道系统还活着。做完之后你会慢慢意识到,它只是等待场景里的配角,做得再精致,也不能替接口速度背锅。够用、稳定、适配系统,就已经很好了。

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

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

立即咨询