17个纯CSS加载动画与进度条特效:可复用样式库
发布时间:2026/10/1 21:54:32来源:尧图网络
去年做后台系统重构的时候最耗时间的不是权限模型也不是接口联调而是一个直径 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圆形脉冲扩散scaleopacity双动画入门08锥形渐变旋转环conic-gradientmask中等09心跳脉冲线stroke-dashoffset描边中等10液体填充波浪SVG 波形 位移循环进阶11基础线性进度widthtransition入门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 viewBox0 0 120 120 classring-svg circle cx60 cy60 r52 classtrack/circle circle cx60 cy60 r52 classindicator/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 的方块上刚好如果方块放大到 40pxperspective要同步调到 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对屏幕阅读器来说毫无意义。至少要加上rolestatus和aria-livepolite让辅助技术知道这里有状态更新。如果只是纯视觉装饰加aria-hiddentrue把它从无障碍树里剔除避免读屏软件反复念。div classspinner rolestatus aria-label内容加载中/div进度条则用原生的roleprogressbar配合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/heightSafari 对合成层尺寸变化的处理和其他浏览器不一致。我的经验是给伪元素画图形时明确写好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的转圈和点阵开始改起来最快收益也最明显。
网站建设高端定制企业官网