news 2026/9/30 7:41:17

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
纯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 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双半圆进度条这件事,本质上也是同一个道理。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/30 7:40:02

OSPF与IS-IS双点双向路由引入:路由回馈成因与Route Tag根治方案

前两天一位做网络集成的朋友给我发消息&#xff0c;说他在实验环境里做了一个OSPF与IS-IS双点双向路由引入的验证&#xff0c;结果发现OSPF域里所有路由器的外部LSA数量几乎翻了一倍。更诡异的是&#xff0c;明明只有两台ASBR&#xff0c;可路由表里同一个前缀却出现了两条外部…

作者头像 李华
网站建设 2026/9/30 7:39:00

Windows内核启动早期ACPI PCI枚举调试:断点组合拳实战

内核调试的朋友应该都有过这种体验&#xff1a;启动早期想看的东西就那么一瞬间&#xff0c;断点没打好&#xff0c;要么进不了现场&#xff0c;要么被无关调用刷屏。最近我在梳理系统引导阶段PCI设备枚举流程时&#xff0c;用了“在ACPI!GetPciAddressWorker函数内的hal!HalGe…

作者头像 李华
网站建设 2026/9/30 7:38:58

Unity按名字查找游戏对象:从Find到字典缓存的性能优化实战

做Unity 3D项目的人&#xff0c;应该都躲不过一个需求&#xff1a;按游戏对象的名字查找对应的对象。策划随时可能丢过来一句"帮我拿到Boss的血量"、“把某个隐藏按钮找出来”、“给所有叫Enemy的怪物挂个特效”&#xff0c;这些需求听着简单&#xff0c;真正下手写的…

作者头像 李华
网站建设 2026/9/30 7:38:58

SpringBoot+Vue+MySQL车间管理系统毕设实战指南

又到一年毕设季&#xff0c;后台收到很多同学来问同一个问题&#xff1a;想做一套工厂车间管理系统&#xff0c;技术栈到底怎么选&#xff1f;我给的建议基本是同一套&#xff1a;SpringBoot Vue MySQL。这三样组合在一起&#xff0c;放在车间管理系统这个场景上&#xff0c;…

作者头像 李华
网站建设 2026/9/30 7:38:27

Rails短信验证码集成实战:从服务商抽象到限流监控

做Rails项目这么多年&#xff0c;短信接口几乎是每个业务系统绕不开的标配&#xff1a;注册验证、登录验证、密码找回、风控通知、订单状态变更&#xff0c;全靠那一条短信撑着。但很多人对"ruby短信接口"的理解停留在"找个服务商、发个HTTP请求、完事"的程…

作者头像 李华
网站建设 2026/9/30 7:37:50

校园网实操:OSPF+RIP双协议互通与Wireshark协议分析

简介&#xff1a;本资源是一份面向高校计算机网络课程设计的完整实践文档&#xff0c;聚焦思科设备搭建真实校园网环境并深入分析主流网络协议&#xff0c;适用于网络工程、信息安全等专业本科生开展课程设计、实验复现与协议原理理解。文档内容结构严谨&#xff0c;涵盖VLAN规…

作者头像 李华