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 class="dashboard"> <div class="gauge" style="--progress: 72%; --label: '当前使用率';"> <div class="gauge__ring"> <div class="gauge__bar"></div> </div> <div class="gauge__value" id="currentValue">72%</div> <div class="gauge__label">当前使用率</div> </div> <div class="gauge" style="--progress: 90%; --label: '目标使用率';"> <div class="gauge__ring"> <div class="gauge__bar"></div> </div> <div class="gauge__value" id="targetValue">90%</div> <div class="gauge__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双半圆进度条这件事,本质上也是同一个道理。