ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

纯CSS3实现双半圆进度条:从渐变到遮罩的完整实战

纯CSS3实现双半圆进度条:从渐变到遮罩的完整实战 1. 双半圆进度条到底是什么为什么2026年还要拿它当考题先给没做过这个组件的朋友描述一下画面页面顶部是一块240像素宽的半圆盘弧线从左侧9点钟方向起步像转速表一样沿着上沿往右爬爬到右侧3点钟方向就是100%。有时候这个半圆盘还会被拆成左右两枚分别显示“实时值”和“目标值”这就是前端需求里非常典型的“双半圆进度条”。这玩意儿在后台数据看板里出现频率极高CPU使用率、今日目标完成度、报名人数达成率、评分综合指数基本都是这个形态。而且它有一个很微妙的特点——看起来像图表但它本质上是一个UI组件。组件就该用CSS做不该动不动上Canvas。面试题里爱出它也是因为它能一次考到你的圆角绘制、渐变着色、遮罩裁剪、自定义属性插值、动效性能优化这一整套CSS基本功。先说结论双半圆进度条完全可以用纯CSS3实现包括它的滑入动画全程不需要写一行JS动效代码。我这里的“拒绝JS”有两层意思第一层是静态渲染不需要JS参与第二层是更重要的——数据更新之后从旧值到新值的过渡动画由CSS Transition或Animation接管不需要requestAnimationFrame不需要setInterval去刷DOM。哪怕你的业务数据确实是由JS拿到的那也只需要给元素赋值一个CSS变量剩下的丝滑动效全部归CSS。这篇文章我会按照自己平时做组件时的思路来写先拆解双半圆的形态和弦理再给一套可以直接抄进项目的代码然后把我实际踩过的坑挑几个重点说透最后聊聊这个组件在真实页面里怎么扩展。内容定位偏实战前端新手能跟着做出来有经验的同学可以重点看第四第五部分那里面有几条坑是不翻源码根本发现不了的。2. 让一条弧线“活”起来CSS3核心原理拆解2.1 用conic-gradient画出精确弧段很多人一提起画弧线下意识就想去用SVG的stroke-dasharray。CSS那边其实有个更方便的武器——conic-gradient锥形渐变。它的原理可以理解为绕着圆心一圈一圈铺扇形颜色角度到哪里颜色就画到哪里。我举个例子下面这行代码能画出一个从左侧起点绕到顶部的渐变弧background: conic-gradient( from 180deg, #0ea5e9 calc(var(--progress) * 1.8deg), transparent 0deg );from 180deg表示从左侧9点钟方向起笔禁时针方向扫过顶部。因为我们要的是半圆总角度是180度所以进度值--progress是百分数的时候需要乘以1.8因为100%进度对应180度1%就是1.8度。transparent 0deg的写法很多人会不习惯它的作用是把剩余部分设为透明这样活动弧以外的区域就不会被污染。这里有一个核心认知CSS的conic-gradient本身不限制角度范围它永远画满360度你能控制的只是颜色的起始和结束位置。所以我们真正要做的事情是让“颜色段”刚好落在我们想要的那片扇形区域里其他的地方全透明。这也是为什么后面一定需要配合遮罩和裁切否则你看到的会是一整个彩色圆盘而不是弧线。2.2 用radial-gradient和overflow裁出半环锥形渐变只能解决“扇形着色”解决不了“环”和“半圆”。要得到一条半圆弧得裁两次第一次把圆盘中间挖空第二次把下半部分丢掉。挖空圆盘最优雅的做法是radial-gradient把它当成一个遮罩层来用-webkit-mask: radial-gradient( circle at center, transparent 0 52%, #000 53% 72%, transparent 73% ); mask: radial-gradient( circle at center, transparent 0 52%, #000 53% 72%, transparent 73% );这段代码的意思是圆心往外52%的半径范围内完全透明53%到72%之间是可见的实心圆环超过73%再次透明。实验起来的效果就是——背景上只留下一个圆环带中间是空洞边缘是虚无。配合前面的conic-gradient最终显示的是一个“只有指定角度有颜色”的圆环碎片。第二步是裁半圆。这里我不建议你用clip-path: polygon去抠复杂形状有个更省事的办法让整个圆盘元素是240×240像素外面包一层只有240×120像素高、且overflow: hidden的容器。这样圆环的下半部分自然被截掉剩出来的就是一个标准的上半圆弧。这个技巧看起来简单但比clip-path好调试得多——因为当你转动transform: rotate()的时候不会被奇怪的多边形路径二次干扰。2.3 property注册后的数值插值才是丝滑关键这是整个方案里最容易被忽略、也最影响成败的一步。先说现象你给一个元素的--progress写transition: --progress 0.8s ease然后把--progress从20%改成80%大多数浏览器会直接“啪”一下跳到80%完全没有任何过渡过程。原因特别直白普通CSS自定义属性在浏览器眼中就是一段字符串。20%和80%对于浏览器来说只是两个不同的字符串它根本不知道该怎么做中间插值。于是过渡被一笔跳过。解决方案是property——把自定义属性“注册”成明确的数值类型让它告诉浏览器“这个值是一种百分比可以计算中间状态。”property --progress { syntax: percentage; inherits: false; initial-value: 0%; }这里syntax是类型声明percentage表示百分比inherits建议设为false避免父级进度值污染子级initial-value是这个属性的初始值也是每次数据重置前页面展示的起始值。注册完之后transition: --progress 0.8s cubic-bezier(0.22, 1, 0.36, 1)才能真正生效浏览器会实实在在算出每一帧该显示多少度弧线看起来就是一条连贯的、丝滑的扫弧动画。property如今早就是主流浏览器的常规能力了这也是我敢在2026年前端场景里大力推荐的关键原因。放在前几年这套方案还需要兼容性地考虑Safari现在可以放心用。3. 直接抄作业一套支持双半圆的可复用组件3.1 结构骨架与CSS变量约定先看HTML结构。我设计的是左右双枚半圆仪表盘布局这也是“双半圆”最常见的业务形态——左边显示当前值右边显示目标值div classdashboard div classgauge style--progress: 72%; --label: 当前使用率; div classgauge__ring div classgauge__bar/div /div div classgauge__value idcurrentValue72%/div div classgauge__label当前使用率/div /div div classgauge style--progress: 90%; --label: 目标使用率; div classgauge__ring div classgauge__bar/div /div div classgauge__value idtargetValue90%/div div classgauge__label目标使用率/div /div /div这里有两个关键约定一个是所有可变数据都走--progress这一个CSS变量HTML内联样式负责给初始值另一个是.gauge__bar是真正画弧线的层.gauge__ring是内部挖孔的遮罩层。之所以把bar放在ring里面是为了让遮罩同时作用于轨道和活动弧确保两者裁剪后半径完全一致不会出现刻度对不齐的问题。CSS骨架如下.dashboard { display: flex; gap: 48px; justify-content: center; padding: 40px; background: #0f172a; } .gauge { position: relative; width: 240px; height: 120px; overflow: hidden; font-family: system-ui, sans-serif; } .gauge__ring { position: absolute; inset: 0; width: 240px; height: 240px; border-radius: 50%; } .gauge__bar { width: 100%; height: 100%; border-radius: 50%; background: conic-gradient( from 180deg, #22d3ee calc(var(--progress) * 1.8deg), #e2e8f0 0deg ); -webkit-mask: radial-gradient(circle at center, transparent 0 52%, #000 53% 72%, transparent 73%); mask: radial-gradient(circle at center, transparent 0 52%, #000 53% 72%, transparent 73%); }轨道和活动弧我故意没有拆成两个独立元素而是让背景渐变里直接把未达到的部分画成灰色#e2e8f0。这样做的好处是省了一个DOM节点坏处是当你想要“只有弧线末端有高亮光晕”这种效果时得再叠一层。就基础版来说这个写法最干净。3.2 滚动的数字怎么跟弧线一起动弧线搞定了但页面中间还有很多数字。比如72%、90%这些数字在UI里通常不是静态文本而是跟着进度一起爬到最终值。纯CSS想让数字也做逐帧动画说实话没有弧线那么方便但也不是完全没办法。如果你对数字的精度要求不高可以用一个“视觉欺骗”的办法——利用CSS的steps()时间函数配合计数器。但我在实际项目里测下来content里的counter值在过渡过程中并不会逐帧变化它只是最终值所以想靠伪元素::after去模拟数字滚动效果并不稳定。更可靠的做法是把数字的“入场动画”做成向上飘入透明度变化最终停在目标值上。视觉上数字和弧线是同时开始运动的弧线滑行期间数字完成淡入整个组件看起来就是“一起活了”。.gauge__value { position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%); font-size: 32px; font-weight: 700; color: #f8fafc; animation: valueIn 0.8s cubic-bezier(0.22, 1, 0.36, 1) forwards; opacity: 0; } keyframes valueIn { 0% { opacity: 0; transform: translateX(-50%) translateY(8px); } 100% { opacity: 1; transform: translateX(-50%) translateY(0); } }如果业务上确实要求数字从0爬到72我的建议是不要死磕CSS直接用一行JS赋值即可document.querySelectorAll(.gauge__value).forEach(el { const target parseInt(el.textContent, 10); let current 0; const step Math.ceil(target / 30); const timer setInterval(() { current step; if (current target) { current target; clearInterval(timer); } el.textContent current %; }, 30); });这里必须诚实地说数字的逐帧滚动确实是JS的强项。所谓的“拒绝JS也能丝滑动效”指的是弧线滑行这个核心动效不需要JS而不是整个页面一个JS都不能有。区分好这个边界你在面试里反而能说得更清楚CSS负责视觉连贯性JS只负责数据变化各司其职。3.3 让进度值从0跑到目标值的完整动效方案大多数场景下页面加载或数据刷新时我们都希望弧线先躺在0%的位置然后平滑滑到当前值。如果数据已经在HTML里写好了可以完全用CSS Animation做到零JS。我在property注册之后加了一段关键帧动画property --progress { syntax: percentage; inherits: false; initial-value: 0%; } .gauge__bar { animation: progressSlide 1.2s cubic-bezier(0.22, 1, 0.36, 1) forwards; } keyframes progressSlide { from { --progress: 0%; } to { --progress: 100%; } }等等这里有一个“变量映射”的细节很关键。真正的最终值不是100%而是每个表自己内联的数值72%和90%。如果煞费苦心地两个表用同一套关键帧就会大家都跑到100%。解决办法有两种一种是把关键帧拆成--current和--target两个变量动画负责从--current过渡到--target另一种是直接用CSS变量做“目标值转发”。我实际操作中更偏好这种写法.gauge__bar { --target: var(--progress); animation: progressSlide 1.2s cubic-bezier(0.22, 1, 0.36, 1) forwards; } keyframes progressSlide { from { --progress: 0%; } to { --progress: var(--target); } }把内联的--progress提前保存到--target里然后动画里从0跑到--target。这样每个表都能跑到自己的目标值而且不需要JS参与。动画结束后浏览器会保留末帧状态不会animation默认结束后会回到初始状态所以要加forwards填充模式。这个细节漏了你刷新页面就会看到弧线画到一半又缩回去非常尴尬。4. 我在双半圆进度条上踩过的坑与修复4.1 旋转起点和视觉起点不一致第一版组件我做出来的时候弧线起点永远在右侧3点钟方向而不是我想要的左侧9点钟。原因是conic-gradient的默认from角度是0度而0度在CSS里指向的是正上方。我一度以为要拿rotate(90deg)去纠正结果连圆环遮罩一起转了整个半圆歪到一边。正确的思维是不要用transform去转渐变要用from参数去改渐变自身的方向。左侧9点钟在CSS坐标系里是180度右侧3点钟是0度顶部是270度或-90度。想要从左往右画到顶起始角度必须是from 180deg。这个坑的隐蔽之处在于视觉调试的时候弧线看起来“挺正常的”但加数字、加刻度线之后就明显错位。如果你最后要叠加刻度文字务必在一开始就把起点定义成业务想要的方位别想着后面旋转救场。4.2 遮罩和背景的圆角边界锯齿半环两侧的切口本来应该是平滑的但我在Safari里看到弧线末端有明显的锯齿和半像素偏移。排查下来发现是遮罩的硬边界过度太陡。修复方式是把遮罩的那几个关键节点做得别太“锐利”留出一点过渡带。原本的写法是transparent 0 52%, #000 53% 72%, transparent 73%我给每个边界都加了2%的软过渡mask: radial-gradient( circle at center, transparent 0 50%, transparent 52%, rgba(0, 0, 0, 0.4) 54%, #000 58%, #000 70%, rgba(0, 0, 0, 0.4) 72%, transparent 74%, transparent 100% );这样遮罩从透明到实心再到透明是渐变的浏览器在缩放或抗锯齿时就不会出现硬边破碎。代价是环的厚度看起来略微模糊但只要过渡带控制在2%-3%肉眼几乎察觉不到。4.3 圆弧顶端和数字底部重叠视觉压迫感重很多双半圆组件会把数字放在半圆的圆心位置但半圆只有上半部分圆心其实处于整个组件的边缘下方——数字很容易和下方的说明文字打架。我的处理方法是把数字放在半圆中心偏上的位置即圆的圆心往上挪一点。由于父容器只有半圆高实际计算要灵活不能直接物理居中对齐.gauge__value { position: absolute; bottom: 26px; left: 50%; transform: translateX(-50%); }这里用bottom大于0来把数字抬离底部边线避免和标题重叠。如果数字要展示两行比如大数字加单位这个值可以再调大。4.4 多个进度条同时跑页面明显卡顿当页面里同时存在8个双半圆进度条每个都在用animation改变--progress时我发现帧率掉了。乍一看会觉得“这只是改一个CSS变量而已”但你要知道CSS动画里的复合型属性比如渐变背景任何一帧的变化都可能触发重绘而不只是合成。conic-gradient本身是绘制型属性逐帧改变角度尤其昂贵。我做的优化有三个第一给动画范围加will-change: background或will-change: transform, background提示浏览器提前分层。但不要滥用元素多了反而会撑爆内存。第二把动画从“同时无限启动”改成“错峰启动”给每个表加上不同的animation-delay让弧线滑行动画在不同时段完成渲染而不是同一帧全部重绘。第三也是最重要的——弧线动画只执行一次之后的数据更新全部走Transition。动画是一次性的Transition是状态变化驱动的它们在性能模型上不一样。页面加载时用Animation播放入场效果数据刷新时改成更新--progress变量由Transition接管这样长时间驻留页面的性能负担就会下来。5. 让这个组件直接落地到真实项目5.1 滚动进视口后再触发入场动画很多页面里的双半圆进度条不在首屏如果在页面加载时就把动画执行完用户滚动到那个区域时看到的是静止状态完全没有“数据在动”的冲击感。我的做法是结合IntersectionObserver给对象加类名触发动画。这段JS极其精简不属于动效循环它只负责“什么时候开始放幻灯片”const gauges document.querySelectorAll(.gauge); const observer new IntersectionObserver((entries) { entries.forEach((entry) { if (entry.isIntersecting) { entry.target.classList.add(in-view); observer.unobserve(entry.target); } }); }, { threshold: 0.3 }); gauges.forEach((gauge) observer.observe(gauge));对应CSS只需要把.gauge__bar的动画加一个限制触发条件.gauge:not(.in-view) .gauge__bar { animation: none; } .gauge.in-view .gauge__bar { animation: progressSlide 1.2s cubic-bezier(0.22, 1, 0.36, 1) forwards; }这样用户滚动到进度条之前弧线是静止的或者隐藏在初始值一旦出现在视野里才真正开始滑入。体验好了很多也顺便省了一部分首屏渲染开销。5.2 用hover和focus状态做纯CSS交互扩展如果产品经理要求“鼠标移上去弧线有一点响应”其实都不用JS。纯CSS可以通过:hover改变进度变量的目标值让弧线从原值微微向上探一下再回来.gauge:hover .gauge__bar { --hover-bump: 4%; --progress: calc(var(--target) var(--hover-bump)); transition: --progress 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); }配合前面的property注册浏览器会平滑地让弧线多走出4%再过0.3秒弹回原位。这种交互反馈全部发生在CSS内部数据层完全无感知很适合给组件增加“活”的质感。同样的思路还可以放到键盘focus-visible上对无障碍也是加分项。进度条在focus时不应该只让文字变色弧线跟着动一动用户立刻能感知到焦点位置。5.3 什么时候该换SVG或Canvas别硬扛我必须把这个边界讲清楚虽然纯CSS方案很好用但它并不是全能选手。如果你的弧线需要渐变描边——比如弧线从蓝色平滑渐变到紫色——CSS的conic-gradient可以做到但要做两层叠加以实现“沿弧线方向渐变”的效果比较复杂。这件事SVG非常擅长一个linearGradient就能解决。如果你需要支持用户拖拽调节进度比如音频播放器的环形进度条可以拖动那Canvas是最自然的选择因为拖拽命中检测判断手指是否落在环带上对CSS来说异常痛苦Canvas可以用数学直接算距离。如果你的进度条精度要求很高比如精确到0.01%并且需要秒级更新Canvas或WebGL的性能模型也更合适。CSS的绘制属性变化在极高频率下还是会吃亏。我的判断标准很朴素数据展示用CSS数据交互用Canvas复杂图形大规模升级用SVG。双半圆进度条在绝大多数看板里就是个数据展示组件所以CSS方案足够而且代码量最少、最容易维护。最后再分享一个很实用的经验写这类组件的时候别急着炫技。先用最土的div嵌套把结构搭出来再用边框或者背景色把每个视觉元素映射到对应节点上最后才去填渐变色、遮罩、动画这些高级属性。这样每一步都能清晰看到问题出在哪一层调试成本会低得多。做前端这么多年我发现真正能扛住线上各种奇怪屏幕的组件往往不是代码最花哨的那个而是结构最规整的那个。CSS3双半圆进度条这件事本质上也是同一个道理。
返回列表