☰
17个纯CSS加载动画与进度条特效:可复用样式库
2026/10/1 21:54:26 网站建设 项目流程

去年做后台系统重构的时候,最耗时间的不是权限模型,也不是接口联调,而是一个直径 24 像素的 loading 圈。产品经理来回改了七版,从“再快一点”到“能不能有点呼吸感”,最后我实在忍不住,把手上搜集的 CSS 动画加载与进度条特效全部整理成了一套可复用的样式库,才把这活儿收尾。这篇文章就是那次整理的产物——17 个纯 CSS 实现的加载动画与进度条特效,全部去掉 JS 依赖,靠transform、conic-gradient、mask、stroke-dasharray这些原生能力跑起来。

它适合几类人:一是正在被“等待体验”折磨的前端同学,二是想把项目里那些 200KB 的 Lottie 动画换掉、给首屏减负的人,三是刚开始学 CSS 动画、看文档能看懂但一写就卡壳的新手。每个特效我都会给出可直接粘贴的代码、参数计算过程,以及我在实际项目里踩过的坑,不讲虚的,拿来就能用。

1. 我为什么宁愿手写 CSS 动画,也不下载现成素材

1.1 体积和清晰度是两笔算得清的账

先说体积。一个 60 帧、尺寸 200×200 的动图 loading,压缩得再狠也要 80KB 到 200KB;如果用序列帧的 APNG 或者 WebP 动画,为了不糊,体积极易翻倍。而一个纯 CSS 的环形加载器,压缩后不到 400 字节,gzip 之后大概 200 字节出头——差距是三个数量级。这不是省那点流量的问题,而是首屏渲染时的一个img请求,可能就把 LCP 往后拖了一两百毫秒。

再说清晰度。位图素材在 2 倍屏、3 倍屏上要么放大发虚,要么得准备多套。CSS 画的图形本质是矢量渲染,无论 DPR 是 1 还是 3,边缘都是显示器物理像素级的锐利。我做过一次对比:同一个 32px 的转圈,GIF 在 MacBook 上肉眼可见锯齿,CSS 版本干净得像用尺子画的。

最后一个理由是主题联动。现在很多系统支持亮色/暗色切换,位图素材要么准备两套图、要么用filter硬调,怎么调都不对味。CSS 版本直接把颜色抽成变量,换肤的时候改一行--active就完事了,这是我最看重的部分。

1.2 一个反直觉的结论:丝滑感多半不来自动画本身

很多人以为 loading 转得丝滑是因为“帧数高”,于是拼命往 60fps 上凑。实际上浏览器渲染 CSS 动画,只要不动width、height、top、left这类触发重排的属性,帧率天然就是满的。真正让人觉得“顺”的,是缓动曲线的选择,以及动画元素与容器的比例关系。

我做过一个小实验:同一个三环旋转动画,把linear换成cubic-bezier(.6,.05,.4,.95),所有人反馈都是“改完之后顺畅多了”。原因很简单,linear是匀速,人眼对匀速旋转的感知是机械的、发涩的;而带一点加速减速的曲线,旋转在起止处有微妙的速度变化,视觉上就有了“重量感”。

另一个反直觉点是间距。三个 6px 的点,间距 4px 和间距 8px,观感完全不同。间距太密显得挤,太疏显得散。我通常用的比例是点直径的 0.75 倍左右,6px 的点配 4~5px 的 gap,这个比例在 1x 到 3x 屏上都不塌。

1.3 这 17 个特效的清单与难度分布

先给一张地图,方便你按需取用。我把它们分成两大类,加载动画偏“不确定态”,进度条偏“确定态”,两者在实现思路上差别很大,后面会分开讲。

编号名称核心属性难度
01双环反向旋转伪元素 +transform入门
02六点呼吸跳动animation-delay错位入门
03骨架屏流光线性渐变 +background-position入门
04方块翻转队列perspective+ 3D 旋转中等
05波浪文字逐字延迟 +translateY入门
06点阵涟漪多重box-shadow+ 延迟中等
07圆形脉冲扩散scale+opacity双动画入门
08锥形渐变旋转环conic-gradient+mask中等
09心跳脉冲线stroke-dashoffset描边中等
10液体填充波浪SVG 波形 + 位移循环进阶
11基础线性进度width+transition入门
12条纹跑马灯进度重复渐变 +background-position入门
13渐变发光描边进度@property角度动画进阶
14锥形环形进度conic-gradient+ CSS 变量中等
15SVG 环形进度stroke-dasharray数学中等
16分段步骤条flex+ 状态类入门
17顶部页面载入条固定定位 + 位移入门

从表里能看出来,真正需要动脑子的只有 04、08、10、13、15 这几个,其余都是组合拳。我建议先把 01、02、03 写熟,这三个覆盖了 80% 的日常场景,剩下的按需查表就行。

2. 拆开看:所有加载动画都逃不出这五个属性

2.1transform和opacity才是动画的两条快车道

浏览器渲染一帧动画,大致要走样式计算、布局、绘制、合成四步。改width触发的是布局,改background-color触发的是绘制,而改transform和opacity,大多数情况下只走最后一步合成,GPU 直接处理,CPU 几乎不参与。

拿进度条举例就很清楚了。如果你用width: 30%到width: 70%做过渡,每一帧浏览器都要重新计算布局,父容器、兄弟元素全都跟着动,长列表里放十几个这样的进度条,滚动就开始掉帧。正确做法是让内层元素宽度固定为 100%,通过transform: scaleX(0.3)来控制进度,scaleX走的是合成层,成本极低。

不过这里有个必须注意的副作用:scaleX会把圆角、边框一起拉扁。如果进度条有圆角或者斜纹图案,缩放后斜纹角度会变形。我的处理方式是,纯色进度条用scaleX,带图案的进度条老老实实用width,但把transition时间压到 300ms 以内,用短时间换取低开销。

2.2animation六个子属性里,最容易被忽略的两个

animation是个复合属性,展开是name、duration、timing-function、delay、iteration-count、direction、fill-mode、play-state。新手最熟的是前三个,后面几个常年被忽略,但恰恰是它们决定了动画的“收尾姿态”。

fill-mode控制动画结束后元素保持哪一帧的样式。默认值none意味着动画一结束,元素立刻弹回初始状态。我第一次写描边环形进度的时候就栽在这上面:进度从 0 走到 100,动画结束的瞬间整圈“唰”地缩回起点,看起来像加载失败。加上forwards之后,元素停在最后一帧,才是正常的表现。

direction的alternate值则能省掉一半关键帧。比如呼吸灯效果,从亮到暗再到亮,如果不用alternate,得写 0%、50%、100% 三个关键帧;用了alternate,只写 0% 和 100% 两个就够,代码直接短一半,维护起来也清爽。

2.3steps()、linear()和cubic-bezier()的三种手感分工

缓动函数是动画的“性格”。我把它们分成三类来理解。

linear是匀速,适合旋转、跑马灯这类本身就应该是恒定速度的运动,用缓动反而怪。cubic-bezier()是主力,可以精确控制加减速。我常用的几个值:cubic-bezier(.4,0,.2,1)是标准的减速进入,适合进度条增长;cubic-bezier(.6,.05,.4,.95)是平滑的往返,适合旋转;cubic-bezier(.34,1.56,.64,1)带点回弹,适合点数跳动,视觉上有点“弹”的活泼感。

steps()是跳变,专门做帧动画质感。比如一个方块用 8 个离散状态跳动,用steps(8)就比用缓动更“像素风”。但steps()有个坑,后面排查章节会细讲:步数和关键帧数量对不上,动画会每次都卡一下。

至于linear(),这是比较新的函数,可以传入一串点来定义任意自定义曲线,用来复刻弹簧效果特别方便,比如linear(0, 0.5 25%, 1.2 60%, 1)就能做出先过冲再回落的手感,不用再堆一堆关键帧。

2.4conic-gradient、mask、clip-path:不写 JS 也能画出任意形状

conic-gradient是环形进度的核心。它的原理是从中心向外按角度铺色,写conic-gradient(#2563eb 72%, #e5e7eb 0)就能画出 72% 的扇形色块。但扇形是实心的,要变成圆环,得配mask把中间掏空:

.ring { --value: 72; --size: 96px; --thick: 10px; width: var(--size); height: var(--size); border-radius: 50%; background: conic-gradient(#2563eb calc(var(--value) * 1%), #e5e7eb 0); mask: radial-gradient( farthest-side, transparent calc(100% - var(--thick)), #000 calc(100% - var(--thick)) ); -webkit-mask: radial-gradient( farthest-side, transparent calc(100% - var(--thick)), #000 calc(100% - var(--thick)) ); }

这里mask用的是硬停靠,两个色标位置相同,中间不会产生羽化过渡,所以掏出来的圆环边缘是干净的。-webkit-mask是给老版 Safari 兜底的,虽然新版已经支持标准写法,但在一些嵌入式 WebView 里还是得留着。

clip-path则擅长做不规则形状,比如多边形、锯齿、裁切动画。我做过一个“分子聚合”的加载效果,就是用clip-path: polygon()每帧换一组坐标,纯 CSS 实现近似逐帧动画,效果相当唬人。

2.5 SVG 环形进度的数学,其实就一个圆周长

很多人对 SVG 环形进度有心理障碍,觉得要算一堆东西。其实核心只有两个属性和一个公式:stroke-dasharray定义虚线的“实线段+空白段”长度,stroke-dashoffset控制从哪开始偏移。知道周长之后,把 dasharray 设成周长,dashoffset 设为“周长 × (1 - 进度百分比)”,进度条就出来了。

半径 52 的圆,周长是 2 × π × 52 ≈ 326.7。

<svg viewBox="0 0 120 120" class="ring-svg"> <circle cx="60" cy="60" r="52" class="track"></circle> <circle cx="60" cy="60" r="52" class="indicator"></circle> </svg>
.ring-svg { width: 120px; height: 120px; transform: rotate(-90deg); } .ring-svg circle { fill: none; stroke-width: 10; } .ring-svg .track { stroke: #e5e7eb; } .ring-svg .indicator { --value: 72; --r: 52; stroke: #2563eb; stroke-linecap: round; stroke-dasharray: calc(2 * 3.14159 * var(--r)); stroke-dashoffset: calc(2 * 3.14159 * var(--r) * (1 - var(--value) / 100)); transition: stroke-dashoffset .4s cubic-bezier(.4, 0, .2, 1); }

那个rotate(-90deg)是为了让 0% 从这个圆的十二点方向开始,不加的话起点在三点钟方向,看起来会很别扭。stroke-linecap: round让进度末端是圆头,比平头精致得多,代价是圆头会往外多出半个描边宽度,做极细进度条时要注意别超出容器。

3. 我最常用的六个加载动画,代码直接抄

3.1 双环反向旋转:一分钟写完却最耐看

这是我在项目里用得最多的一个。两个伪元素各画一个半透明的环,一个顺时针、一个逆时针,配合起来有种机械齿轮的精密感。

.spinner { --size: 32px; --thick: 3px; --track: #e5e7eb; --active: #2563eb; width: var(--size); height: var(--size); position: relative; } .spinner::before, .spinner::after { content: ""; position: absolute; border-radius: 50%; border: var(--thick) solid transparent; } .spinner::before { inset: 0; border-top-color: var(--active); border-right-color: var(--active); animation: spin 1s cubic-bezier(.6, .05, .4, .95) infinite; } .spinner::after { inset: calc(var(--size) / 4); border-bottom-color: var(--active); border-left-color: var(--active); animation: spin 1s cubic-bezier(.6, .05, .4, .95) infinite reverse; } @keyframes spin { to { transform: rotate(1turn); } }

inset: calc(var(--size) / 4)让内环缩到外环的四分之一大小,视觉上有层次。我用1turn而不是360deg,纯粹是因为可读性更好,而且在拼接多个旋转时不用换算。

这里踩过一次坑:用border-color: var(--active) transparent transparent transparent这种四值写法也可以,但一旦变量改动,transparent的位置容易写错,导致只剩一条弧。用border全透明 + 单独指定border-top-color更稳,因为透明是默认状态,不会漏。

3.2 六个点的呼吸跳动:关键在延迟错位

点状加载器的灵魂不在动画本身,而在animation-delay的错位节奏。我见过很多实现是0s、0.1s、0.2s均匀递增,出来效果像机器人在敲键盘,很死板。真正舒服的节奏,是延迟略小于动画周期的等分值。

.dots { display: flex; gap: 5px; align-items: flex-end; height: 16px; } .dots i { width: 6px; height: 6px; border-radius: 50%; background: var(--active, #2563eb); animation: bounce 1.2s cubic-bezier(.34, 1.56, .64, 1) infinite; } .dots i:nth-child(2) { animation-delay: .15s; } .dots i:nth-child(3) { animation-delay: .3s; } .dots i:nth-child(4) { animation-delay: .45s; } .dots i:nth-child(5) { animation-delay: .6s; } .dots i:nth-child(6) { animation-delay: .75s; } @keyframes bounce { 0%, 70%, 100% { transform: translateY(0) scale(.72); opacity: .4; } 35% { transform: translateY(-8px) scale(1); opacity: 1; } }

动画周期 1.2 秒,六个点延迟间隔 0.15 秒,总共错开 0.75 秒,正好占周期的 62.5%。这个比例是我反复试出来的,超过 70% 会让最后一个点还没抬起来第一个点又开始动了,视觉上糊成一团。低于 50% 则显得背后一半时间在发呆。

align-items: flex-end是让点从同一条基线往上跳,如果用默认的stretch,跳动的时候整行高度会变化,把下方内容顶来顶去,这是新手最常见的副作用。

3.3 骨架屏流光:把background-position用出花

骨架屏比转圈更高级的地方在于,它提前把内容的形状画出来,用户等待时的心理预期是“东西已经在那了,只是还没显影”,比对着一个空转的圈焦虑感低很多。核心是让一条高光带斜着扫过。

.skeleton { --base: #eceef1; --shine: #f7f8fa; --speed: 1.4s; background: linear-gradient( 90deg, var(--base) 25%, var(--shine) 37%, var(--base) 63% ); background-size: 400% 100%; animation: shimmer var(--speed) ease-in-out infinite; border-radius: 6px; } @keyframes shimmer { 0% { background-position: 100% 50%; } 100% { background-position: 0 50%; } }

background-size: 400% 100%是关键。如果保持默认的 100%,渐变会被拉伸到整个元素宽度,高光带会变得很宽很糊。放大到 400% 之后,渐变里的那条亮色段实际宽度大约是元素宽度的 4%,扫过去才有“细细一道光”的感觉。

我通常给它套一个容器,里面三行不同的宽度:标题行 40%、正文行 100%、正文行 72%。宽度差异让骨架看起来像真实排版,比三条等长的灰条自然得多。另外别忘了给骨架块加上最小高度,否则空 div 高度为 0,什么都看不到,这是新手调试时最容易懵的点。

3.4 方块翻转队列:3D 的正确用法

这个效果看起来复杂,其实就三个属性:perspective给容器,transform-style可加可不加,然后在关键帧里同时转 X 轴和 Y 轴。

.cubes { display: flex; gap: 4px; perspective: 200px; } .cubes i { width: 10px; height: 10px; background: var(--active, #2563eb); border-radius: 2px; animation: flip 1.6s cubic-bezier(.7, 0, .3, 1) infinite; } .cubes i:nth-child(2) { animation-delay: .12s; } .cubes i:nth-child(3) { animation-delay: .24s; } .cubes i:nth-child(4) { animation-delay: .36s; } @keyframes flip { 0%, 60%, 100% { transform: rotateX(0) rotateY(0); opacity: 1; } 30% { transform: rotateX(-180deg) rotateY(180deg); opacity: .55; } }

perspective: 200px加在父容器上,所有子方块共享同一个视点,翻转时有统一的透视关系。如果加在方块自己身上,每个方块各有一套透视,看起来会像各自歪着翻,很乱。这个值也别太小,200px 用在 10px 的方块上刚好,如果方块放大到 40px,perspective要同步调到 600px 以上,不然会被透视拉得变形。

3.5 波浪文字与点阵涟漪

波浪文字的实现思路是把每个字符包一层span,然后统一给动画、各自延迟:

.wave span { display: inline-block; animation: wave 1.4s ease-in-out infinite; } .wave span:nth-child(1) { animation-delay: 0s; } .wave span:nth-child(2) { animation-delay: .08s; } .wave span:nth-child(3) { animation-delay: .16s; } .wave span:nth-child(4) { animation-delay: .24s; } @keyframes wave { 0%, 60%, 100% { transform: translateY(0); } 30% { transform: translateY(-6px); } }

display: inline-block必须加,否则transform对行内元素不生效——这个坑我在早期项目中踩过不止一次,看着代码没问题,就是不动,查半天才发现是 inline 元素不能变换。

点阵涟漪则是用box-shadow一次性画出多个点,再整体缩放:

.ripple { width: 8px; height: 8px; border-radius: 50%; background: var(--active, #2563eb); box-shadow: 0 0 0 0 rgba(37, 99, 235, .5), 0 0 0 0 rgba(37, 99, 235, .35), 0 0 0 0 rgba(37, 99, 235, .2); animation: ripple 1.6s ease-out infinite; } @keyframes ripple { to { box-shadow: 0 0 0 12px rgba(37, 99, 235, 0), 0 0 0 24px rgba(37, 99, 235, 0), 0 0 0 36px rgba(37, 99, 235, 0); } }

三层box-shadow同步扩散,因为透明度衰减速度一致,看起来就像一圈圈涟漪往外荡。这种方式比堆三个真实元素省 DOM,代价是不能单独控制每一层的时序,想要更精细的错落感,还是得用真实元素。

3.6 无 JS 的不确定进度条纹

不确定态进度条(就是那种来回扫、不告诉你具体百分比的)在轮询接口、上传文件时很常用。用伪元素加一条渐变光带,左右往返就行。

.bar { height: 4px; border-radius: 999px; background: var(--track, #e5e7eb); overflow: hidden; position: relative; } .bar::after { content: ""; position: absolute; inset: 0; width: 40%; border-radius: inherit; background: linear-gradient( 90deg, transparent, var(--active, #2563eb), transparent ); animation: slide 1.4s cubic-bezier(.65, .05, .36, 1) infinite; } @keyframes slide { 0% { transform: translateX(-120%); } 100% { transform: translateX(320%); } }

translateX(320%)这个数不是随手写的。光带宽度是容器的 40%,起始位置在 -120%(即完全藏在左边外面,还多留了 20% 缓冲保证不露头),要完全滑出右边,需要移动的距离是 100% ÷ 40% = 250%,再加上起点偏移 120%,所以是 370%?不,这里要按光带自身宽度计算:位移的百分比是相对于元素自身宽度的,所以要让光带从容器左侧外侧走到右侧外侧,需要位移 (100% + 40%) / 40% × 100% = 350% 左右,我取 320% 是为了让它提前一点点消失,视觉上不会“撞墙”停住,节奏更自然。

4. 进度条:确定态和不确定态是两条技术路线

4.1 确定态用宽度还是缩放,取决于有没有图案

确定态进度条最简单,一个容器加一个内层条,改内层宽度或缩放值就行。

.progress { --value: 0; height: 8px; border-radius: 999px; background: #e5e7eb; overflow: hidden; } .progress > span { display: block; height: 100%; width: calc(var(--value) * 1%); border-radius: inherit; background: linear-gradient(90deg, #60a5fa, #2563eb); transition: width .35s cubic-bezier(.4, 0, .2, 1); }

用calc(var(--value) * 1%)而不是直接写百分比,是为了让调用方只传数字,比如style="--value: 68",不用记着带单位,这在组件化场景里能省不少事。transition时间我定在 0.35 秒,比很多人常用的 0.2 秒略长。原因是进度往往以 100ms 到 500ms 的间隔跳变,过渡太短会一顿一顿的,太长又跟不上真实进度,0.35 秒是体感和准确性比较平衡的位置。

如果你要做的是带条纹的进度条,就不能用scaleX,条纹会被压扁变形,必须走width路线。

4.2 条纹和流光:给等待加一点“正在工作”的暗示

条纹进度条的原理是给内层条叠两层背景:一层是重复的斜向条纹,一层是渐变底色,然后让条纹的background-position循环位移。

.progress > span { background-image: repeating-linear-gradient( 45deg, rgba(255, 255, 255, .28) 0 8px, transparent 8px 16px ), linear-gradient(90deg, #60a5fa, #2563eb); background-size: 32px 100%, 100% 100%; animation: stripes 1s linear infinite; } @keyframes stripes { to { background-position: 32px 0, 0 0; } }

background-size里的第一个值 32px,正好等于条纹的一个完整周期(8px 白 + 8px 透明,重复两组),所以位移 32px 后视觉上无缝衔接,看起来是无限流动的。如果位移值和周期对不上,会出现“跳一下”的断裂感,这是调试条纹动画最常遇见的问题。

心理学上有个说法,运动中的元素会让用户觉得“系统在干活”,静止的元素则会让人怀疑卡死了。所以在真实项目里,我倾向于把长耗时操作都加上一点流动感,哪怕进度值是假的。当然前提是要诚实,不要给用户造成“快好了”的误导。

4.3 渐变描边的呼吸光:@property让角度也能过渡

让一个环形或胶囊形的边框颜色流动起来,是个很吸引眼球的效果。传统做法是旋转背景,但那样整个元素都在转。更优雅的方式是用conic-gradient配合@property声明一个可插值自定义属性:

@property --angle { syntax: "<angle>"; inherits: false; initial-value: 0deg; } .border-glow { padding: 3px; border-radius: 999px; background: conic-gradient( from var(--angle), #2563eb, #22d3ee, #a855f7, #2563eb ); animation: rotate-angle 3s linear infinite; } @keyframes rotate-angle { to { --angle: 360deg; } }

这里的关键是@property。默认情况下,浏览器不支持对conic-gradient的角度做补间,动画会变成一帧一帧地跳。声明了syntax: "<angle>"之后,浏览器知道这个值是角度类型,就能在关键帧之间平滑插值,颜色流动才顺滑。

不过要注意兼容性。@property在较新的主流浏览器上支持得不错,但如果你要覆盖较老的版本或者某些定制内核的浏览器,就得准备一个降级方案,比如把流动效果换成静态渐变加box-shadow呼吸,至少不会全白。

4.4 数字滚动:别让好看的进度配一个丑数字

进度条旁边的百分比数字,很多人直接用文本更新,每次变化都是硬切换,跳得很生硬。我一般用等宽字体加font-variant-numeric: tabular-nums,让所有数字宽度一致,避免 1 比 8 窄导致的左右抖动。

.progress-value { font-variant-numeric: tabular-nums; font-feature-settings: "tnum"; min-width: 4ch; text-align: right; }

min-width: 4ch是预留空间,因为进度从 0% 到 100%,字符数会从 2 位变成 3 位,如果不预留,整个进度条会被数字顶得往左移。tabular-nums让数字等宽,这样从 9 变到 10 的时候不会发生位移跳动。这两行加不加,肉眼差别巨大,但很多人直到被设计师提出来才意识到。

5. 性能与可访问性:两个我踩得最惨的地方

5.1will-change不是越多越好

will-change: transform会提前把元素提升为独立的合成层,让动画走 GPU。听上去很美好,但它的代价是显存占用。如果一个列表里有 50 个加载项,每个都加了will-change,就会产生 50 个合成层,显存一下子被吃光,反而比不加更卡,尤其在低端安卓机上非常明显。

我的原则是:只在“这个动画正在发生”的时候加。比如轮询中的进度条,加载的时候给类名挂上will-change,加载完了就移除。CSS 里可以这样写:

.loading-active { will-change: transform, opacity; }

更激进一点的做法是干脆不加。实测下来,transform和opacity动画即使在默认情况下,浏览器也会自动为正在动画的元素创建合成层,手动声明往往只是提前了一点,收益不大。我现在的默认策略是:先不加,测出来掉帧再针对性加。

5.2prefers-reduced-motion不是可选项

有些用户因为前庭功能障碍,会对持续运动、缩放、旋转的画面产生眩晕甚至不适。操作系统层面提供了“减少动态效果”的开关,浏览器会通过媒体查询暴露出来。作为组件的提供方,尊重这个设置是基本素养,也是合规要求的一部分。

@media (prefers-reduced-motion: reduce) { .spinner::before, .spinner::after, .dots i, .skeleton, .bar::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; } }

注意animation-duration: .01ms而不是0ms,某些浏览器在时长为 0 时会忽略整个动画规则,写一个极小值更保险。同时要注意,把动画停掉之后,加载指示器得有一个静态的替代表现,比如显示一个静止的图标加“加载中”文字,不能停掉之后一片空白。

5.3 加载组件的语义标注

div.spinner对屏幕阅读器来说毫无意义。至少要加上role="status"和aria-live="polite",让辅助技术知道这里有状态更新。如果只是纯视觉装饰,加aria-hidden="true"把它从无障碍树里剔除,避免读屏软件反复念。

<div class="spinner" role="status" aria-label="内容加载中"></div>

进度条则用原生的role="progressbar",配合aria-valuenow、aria-valuemin、aria-valuemax。不确定态的进度条不加aria-valuenow,只留aria-label即可,表示无法确定具体进度。

5.4 移动端的容器高度与变形原点

移动端浏览器地址栏收起展开会改变视口高度,用100vh做全屏加载遮罩时,地址栏一收,遮罩底部会露出白边。解决办法是用100dvh(动态视口高度)替代,或者干脆给遮罩加position: fixed; inset: 0,让它跟着视口走,不要依赖高度单位。

另一个坑是transform-origin。默认值是元素中心,但如果你的加载元素是放在一个靠左对齐的容器里,而动画中用了缩放,缩放的中心是元素自身中心,看起来会有点偏。做跑马灯、波纹这类效果时,如果发现扩散方向不对,先检查transform-origin。

6. 排查链路实录:四个“明明写了却没生效”的现场

6.1 动画结束弹回原位:先查fill-mode

现象是:进度描边从 0 走到一圈,然后“啪”一下缩回。排查顺序应该是这样的。

先看animation是不是简写把fill-mode覆盖掉了。animation: spin 1s linear这种简写里没写fill-mode,它会用默认值none,而不是继承你单独写在上面的animation-fill-mode: forwards。简写会重置所有子属性,这是 CSS 里非常经典的陷阱。我当时的代码是:

/* 错误示范 */ .indicator { animation-fill-mode: forwards; animation: dash 1s ease; /* 这里把 fill-mode 重置了 */ }

正确写法是把forwards放进简写:animation: dash 1s ease forwards;,或者干脆把所有子属性单独写,不用简写。我现在写动画基本都用长写,虽然啰嗦,但出了问题一眼能看出来。

6.2 渐变动画毫无过渡:可插值类型的限制

现象:给background: linear-gradient(...)换成另一个渐变,transition完全不生效,直接跳变。原因是渐变属于图像类型,浏览器无法在两张图片之间插值。这不是 bug,是规范限制。

解法有两条。一是用@property把渐变里的颜色或角度抽成可插值变量,让浏览器只对变量做过渡;二是改成叠一层伪元素,用opacity过渡,把两个渐变分别放在主体和伪元素上,通过透明度切换实现“渐变淡入”。

我一般选第二条,兼容性更好。代码结构是主体放旧渐变,::before放新渐变,transition: opacity控制::before的透明度。看起来绕,但稳。

6.3 伪元素动画在 Safari 下闪一下就消失

现象:Chrome 里正常的旋转环,在 Safari 里出现一瞬间就消失了。这个问题的根因通常是伪元素被创建了独立的合成层,但又没有明确的尺寸或者content声明有问题。

排查步骤:先确认content: ""是不是写在了伪元素上而不是共享规则里;再检查是否给伪元素设了position: absolute但父元素没有position: relative,导致它跑到了可视区外面;最后看是不是动画里改了width/height,Safari 对合成层尺寸变化的处理和其他浏览器不一致。

我的经验是,给伪元素画图形时,明确写好inset: 0配合父元素的position: relative,尺寸通过父元素控制,伪元素自己不设宽高,能避开绝大多数这类玄学问题。

6.4steps()每次都卡一下:步数和关键帧对不齐

steps(n)会把动画区间切成 n 段,每段结束后跳变一次。如果关键帧里定义了 4 个状态点(0%、33%、66%、100%),但steps()写的是 5,就会出现某一段没有对应的状态,动画在那一瞬间会“卡住”,看起来像掉帧。

正确做法是让steps()的步数和关键帧的状态数一致,或者干脆就用steps(n, end)配合一段线性的位置变化,比如跑马灯位移,这样步数只影响离散程度,不会卡顿。这个细节文档里写得很隐晦,我是用慢速播放一帧一帧数出来的。

7. 把这 17 个特效整理成能复用的组件库

7.1 用三组 CSS 变量统一所有参数

收集来的特效如果各写各的,用在项目里会非常痛苦。我给每个特效都抽了三组变量:尺寸类(--size、--thick)、颜色类(--track、--active)、节奏类(--speed、--delay)。调用时只需要覆盖需要的部分,其余走默认值。

.spinner { --size: 32px; --thick: 3px; --track: #e5e7eb; --active: currentColor; --speed: 1s; }

注意--active: currentColor这个默认值。它让加载器自动继承父元素的文字颜色,在做深色主题时,只要父容器文字色改了,加载器自动跟着变,一行代码都不用动。这是我在这套库里最满意的一个设计。

7.2 命名和组织:按形态分文件,不按来源分

我的目录是按形态分的:spin/、dots/、skeleton/、progress/、ring/,每个下面一个 CSS 文件。为什么不按“从哪收集的”来分?因为实际开发时,你的检索路径是“我要一个转圈的”,而不是“我要第 8 号特效”。按形态分类,找起来是零成本的。

命名统一用ui-前缀加形态名加修饰符,比如ui-spinner、ui-spinner--small、ui-progress--striped。避免用loading1、loading2这种,半年后自己都分不清哪个是哪个。

7.3 体积核算与按需引入

17 个特效全部写出来,未压缩的 CSS 大约是 12KB 左右,gzip 之后大概 3KB 出头。这个体积完全可以整体引入,没必要做复杂的按需拆分。但如果你只打算用其中 3 个,我还是建议手动拷出来,因为动画相关的 CSS 一旦有了全局的@keyframes,命名冲突的风险比那 2KB 的体积更值得警惕。

我现在的做法是:库文件里所有@keyframes都带前缀,比如@keyframes ui-spin、@keyframes ui-bounce,避免和项目里其他地方定义的同名动画互相覆盖。这个坑很隐蔽,表现是两个不同地方的动画行为一模一样,查起来能耗掉一下午。

7.4 从“收藏”到“用上”的三条自我约束

最后分享我给自己定的三条规矩,算是把这些特效真正落地的经验。第一,同一个页面里绝不同时出现超过两种加载动画,视觉语言要统一,混搭会显得很业余。第二,动画周期不超过 1.6 秒,超过这个时长,用户会开始怀疑是不是卡住了,宁可让循环快一点。第三,加载动画只在真正有等待时出现,如果接口 200 毫秒就回来了,加个延迟阈值,先等 300 毫秒再显示,避免一闪而过的闪烁——那种闪一下的加载器,比不显示还难受。

这套东西我从整理完到现在用了大半年,中间陆续替换掉了项目里三个 Lottie 动画和两个 GIF,首屏资源少了将近 200KB,换肤逻辑也从一堆条件判断变成了改一行变量。如果你也在做类似的事,建议先从那几个纯transform的转圈和点阵开始,改起来最快,收益也最明显。

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

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

立即咨询