跳到主要内容CSS3 双半圆进度条实战:拒绝 JS 也能丝滑旋转 | 极客日志HTML / CSS大前端
CSS3 双半圆进度条实战:拒绝 JS 也能丝滑旋转
本文详解如何使用 CSS3 结合少量 JavaScript 实现双半圆进度条。核心思路是利用两个绝对定位的半圆容器,通过 clip-path 裁剪和 transform 旋转模拟进度变化。超过 50% 时切换层级与旋转逻辑,配合 CSS 变量动态控制角度。方案性能优于 SVG,兼容主流浏览器,但需注意 Safari 边缘渲染问题及复杂场景下的维护成本。适合单色环形加载动画等轻量级需求。
CSS3 双半圆进度条实战:拒绝 JS 也能丝滑旋转
说实话,我到现在还记得三年前那个暴雨夜。老板拍着我肩膀说:"小王啊,这个圆形进度条,不要搞那些花里胡哨的 SVG,也不要 Canvas,就用纯 CSS,要轻量,要优雅,要丝滑。"我当时年轻啊,以为就是个圆环嘛,能有多难?结果这一脚踩进去,直接让我怀疑人生到凌晨三点。
市面上搜出来的教程,十个有九个是教你用 SVG 的 stroke-dasharray,剩下那个是 Canvas 画弧。我就奇了怪了,CSS3 明明有 border-radius,有 transform,有 clip-path,怎么就没人好好讲讲"两个半圆拼圆环"这种原生方案?难道大家都觉得这种写法太野路子,上不了台面?
今天必须把这事掰扯清楚。不是为了炫技,就是单纯咽不下这口气。而且我发誓,这次一定要写得比那些 AI 生成的教程像人话——毕竟当年被坑的时候,看着那些"首先我们需要创建一个容器"的八股文,我真的想把键盘砸了。
先别急着写代码,咱们聊聊这俩半圆到底怎么"打架"
很多人一看到圆形进度条就怂,觉得要什么三角函数、弧度计算。其实吧,真没那么玄乎。核心思路土得掉渣:左边藏一个半圆,右边藏一个半圆,让它们转起来互相遮挡,露出来的部分就是进度。
想象一下,你手里有两个半圆形的饼干,把它们背靠背拼在一起,正好是个圆。现在左边的饼干固定不动,右边的饼干开始顺时针转。当右边转到 180 度时,它正好把左边完全盖住,这时候进度就是 50%。如果继续转,右边的饼干会跑到左半边去,这时候就需要左边那个"卧底"出来接力了。
这逻辑听起来像不像两个人在抢椅子?前 50% 右边那个人疯狂表演,后 50% 换左边的人上场。这就是为什么纯 CSS 方案处理 0-50% 和 50-100% 要用两套逻辑——因为超过 180 度后,你光靠一个半圆转是盖不住整个圆的,必须左右切换。
那个旋转角度计算,其实就是小学数学:360 度对应 100%,所以 1% 是 3.6 度。想要显示 30% 的进度?右边转 30 * 3.6 = 108deg 就行了。但如果想要 70% 呢?右边先转到 180 度(也就是 rotate(180deg)),然后左边那个从 0 度开始转,70 - 50 = 20%,20 * 3.6 = 72deg,所以左边转 72 度。就这么简单,别被那些动不动就搬出π和弧度的教程吓到了。
这里有个坑我得提前说:很多人搞不清 transform-origin 到底该设在哪。如果你让右边的半圆绕着它自己的中心转,那它只会原地打转,根本盖不住左边。所以必须把旋转中心设在圆的直径上——对于右半圆来说,就是 left: 0,也就是整个圆的中心点。这样它转起来才会像扇子的合页一样,沿着直径扫过去。
HTML 结构:大道至简,三个 div 走天下
我见过有人为了这个进度条嵌套五六个 div,还美其名曰"语义化"。语义化个鬼啊,这就是个视觉组件,又不是给盲人读屏用的。我的原则是:能少一层绝不多一层,层级越深,后期调试越想死。
最精简的结构长这样:
<div class="progress-circle" style="--percent: 75;">
<div class="half-container left">
<div class="progress-bar"></div>
</div>
<div class="half-container right">
<div class="progress-bar">
</div>
</div>
<div class="center-mask">
</div>
</div>
看到没?就三个 div。.half-container 包裹了两个绝对定位的半圆,.center-mask 是用来把中间掏空的——如果你想要的是实心饼图,这层可以删掉。
等等,我好像漏说了什么。对,CSS 变量。现在都用 2026 年了,别再用 JS 去算角度然后写内联样式了,直接用 CSS 自定义属性,JS 只需要改一个 --percent 的值,剩下的交给 CSS 自己算。这才是现代前端该有的样子:
.progress-circle {
--percent: 0;
}
这样后面配合 JS 的时候,只需要 element.style.setProperty('--percent', 当前值),比操作 class 或者直接改 transform 优雅一万倍。
CSS 魔法:让这两个半圆听话地转起来
先上基础样式,把圆环的骨架搭起来。这里的关键是 overflow: hidden 和 border-radius: 50% 的配合,前者负责切掉多余的半圆,后者负责让容器变圆。
.progress-circle {
width: 200px;
height: 200px;
border-radius: 50%;
position: relative;
background: #e0e0e0;
--percent: 0;
}
.half-container {
position: absolute;
width: 100%;
height: 100%;
border-radius: 50%;
overflow: hidden;
}
.half-container.right {
clip-path: inset(0 0 0 50%);
}
.half-container.left {
clip-path: inset(0 50% 0 0);
}
.progress-bar {
width: 100%;
height: 100%;
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
.half-container.right .progress-bar {
transform-origin: left center;
transform: rotate(calc(min(var(--percent), 50) * 3.6deg));
}
.half-container.left .progress-bar {
transform-origin: right center;
transform: rotate(calc(max(var(--percent) - 50, 0) * 3.6deg - 180deg));
}
看到这段 CSS 你可能有点懵,特别是那个 calc 里面套 min 和 max 的操作。这是 CSS 比较函数(CSS Comparison Functions),2020 年之后的浏览器都支持了。它的作用是:不需要 JS,CSS 自己就能判断当前进度在哪个区间,该转多少度。
- 当
--percent 是 30 时,min(30, 50) 等于 30,所以转 30 * 3.6 = 108deg,显示右半边的一部分
- 当
--percent 是 70 时,min(70, 50) 等于 50,所以转 50 * 3.6 = 180deg,右半圆完全显示
- 当
--percent 是 30 时,max(30, 50) 等于 50,所以 (50 - 50) * 3.6 - 180 = -180deg,左半圆完全隐藏在左边
- 当
--percent 是 70 时,max(70, 50) 等于 70,所以 (70 - 50) * 3.6 - 180 = 72 - 180 = -108deg,左半圆从隐藏状态转出
空心圆环的秘密:中间挖个洞就完事了
上面代码里的 .center-mask 还没写样式。这其实更简单,就是一个绝对定位的圆形,背景色跟页面背景一样,盖在最中间,营造出"只有边框在动"的效果。
.center-mask {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 160px;
height: 160px;
background: white;
border-radius: 50%;
z-index: 10;
box-shadow: inset 0 2px 4px rgba(0,0,0,0.1);
}
如果你想要的是实心饼图(像那种磁盘空间占用图),直接把 .center-mask 删掉就行。或者把它的背景色设成半透明,能看到后面的内容,那就是另一种风格了。
这里有个细节:如果父容器有背景图而不是纯色背景,.center-mask 的背景色就不能写死成 white 了,得用 background: inherit 继承父容器的背景,或者用 backdrop-filter: blur(10px) 搞毛玻璃效果。不过后者性能开销比较大,移动端慎用。
动效调教:怎么转才不会像抽风
很多人写 transition 就写个 all 0.3s ease,太敷衍了。进度条这种需要精确控制的动画,得用 cubic-bezier 自定义贝塞尔曲线。我上面用的 cubic-bezier(0.4, 0, 0.2, 1) 是 Material Design 的标准缓动曲线,特点是开始快中间慢结束快,看起来比较自然。
如果你想要那种"咔哒咔哒"的机械感,可以用 steps(10),让进度条一格一格地跳。如果是加载中的无限循环,那就不能用 transition 了,得用 @keyframes:
@keyframes rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.loading .progress-bar {
animation: rotate 1s linear infinite;
transition: none;
}
还有个坑是关于旋转方向的。CSS 的旋转默认是顺时针,但有时候你会发现进度条从 100% 回到 0% 的时候,它不是直接缩回去,而是顺时针转一大圈(经过 360 度)回到 0 度,看起来就像抽风一样。这是因为浏览器觉得从 350 度到 10 度,顺时针只要转 20 度,逆时针要转 340 度,所以选了顺时针——但它不知道你是想"倒退"进度啊!
解决办法是确保角度计算不会出现这种"绕远路"的情况。比如用 JS 控制的时候,如果新值比旧值小,先让旧值减 360 度,这样浏览器就会选择逆时针方向了。或者干脆用 transition: none 瞬间跳过去,然后再开动画。
真实世界的翻车现场
Safari 的 1px 毛边惨案
你以为代码写完就完事了?Too young。在 Safari 上,两个半圆的接缝处会出现一条细细的白线或者黑线,特别是当进度接近 50% 的时候。这是因为浏览器的抗锯齿算法在边缘处理上有点抽风,两个图形拼接的地方没有完全对齐。
我的解决方案是:让两个半圆稍微重叠 1px。把右半圆的宽度设成 calc(50% + 1px),左半圆也往右偏移 1px。这样它们就有 1px 的重叠区域,接缝处的缝隙就被盖住了。虽然理论上会有 1px 的颜色叠加,但因为进度条是纯色,叠加后也没啥明显变化。
.half-container.right {
clip-path: inset(0 0 0 50%);
width: calc(50% + 1px);
}
.half-container.left {
clip-path: inset(0 50% 0 0);
left: -1px;
width: calc(50% + 1px);
}
动态修改时的闪烁黑线
有时候用 JS 动态改百分比,特别是在 50% 那个临界点切换的时候,会出现一瞬间两个半圆都显示或者都隐藏的闪烁。这是因为 DOM 更新和样式计算有个时间差,浏览器先渲染了 z-index 变化,还没来得及算新的 transform。
我的土办法是:在修改百分比前,先把 transition 关掉,改完后再打开。或者更简单粗暴的,给容器加 will-change: transform,让浏览器提前把图层准备好。
circle.style.transition = 'none';
circle.style.setProperty('--percent', 新值);
circle.offsetHeight;
circle.style.transition = 'transform 0.6s cubic-bezier(0.4, 0, 0.2, 1)';
transform 坐标系错乱
如果你的进度条父容器或者祖先元素也有 transform 属性(比如做了缩放、旋转),那子元素的 transform-origin 就会基于那个被变换过的坐标系,而不是原来的视口坐标系。结果就是旋转中心跑偏,进度条转得歪七扭八。
这问题没有完美的 CSS 解决方案,要么确保进度条的容器不要有 transform 祖先,要么用 JS 实时计算正确的旋转中心。或者换个思路,不用 transform: rotate,改用 clip-path: polygon() 来切出扇形,虽然计算复杂点,但不受 transform 层级影响。
让同事喊 666 的骚操作
技巧一:空心实心一键切换
给容器加个 data-type 属性,CSS 里用属性选择器判断:
.progress-circle[data-type="pie"] .center-mask {
display: none;
}
.progress-circle[data-type="ring"] .center-mask {
display: block;
}
JS 只需要改个属性:circle.setAttribute('data-type', 'pie'),瞬间从加载动画变成磁盘占用图。
技巧二:阴影提升质感
别小看 box-shadow,用好了能让你的进度条从"程序员画的"变成"设计师画的"。外发光加内阴影,层次感就出来了:
.progress-circle {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06), inset 0 2px 4px rgba(0,0,0,0.05);
}
.center-mask {
box-shadow: inset 0 2px 4px rgba(0,0,0,0.1);
}
技巧三:封装成 Mixin
如果你用 Sass/Less,把这个逻辑封装起来,下次复制粘贴:
@mixin circle-progress($size: 200px, $stroke: 20px, $color: #667eea) {
width: $size;
height: $size;
border-radius: 50%;
position: relative;
background: #e0e0e0;
.half-container {
position: absolute;
width: 100%;
height: 100%;
border-radius: 50%;
overflow: hidden;
&.right { clip-path: inset(0 0 0 50%); }
&.left { clip-path: inset(0 50% 0 0); }
}
.progress-bar {
width: 100%;
height: 100%;
background: $color;
transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
.center-mask {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: $size - ($stroke * 2);
height: $size - ($stroke * 2);
background: white;
border-radius: 50%;
}
}
.my-progress { @include circle-progress(150px, 15px, linear-gradient(90deg, #f093fb 0%, #f5576c 100%)); }
这方案到底香不香?
说实话,写到这我都累了。这套方案优点很明显:零 JS 依赖(如果不算那行改 CSS 变量的代码),性能比 SVG 操作 DOM 节点快,比 Canvas 轻量,老浏览器(IE10+)勉强能跑。
但缺点也很致命:超过 50% 后的逻辑确实反人类,我上面写的那些 minmax 计算,过三个月我自己都看不懂。而且如果你想搞那种多色分段进度条(比如 0-30% 红色,30-70% 黄色,70-100% 绿色),这套方案直接 gg,因为两个半圆都是单色的,换颜色就得换元素,复杂度爆炸。
还有,响应式适配比较麻烦。SVG 可以 viewBox 自适应,Canvas 可以重绘,CSS 这个方案如果容器大小变了,你得重新算 border-radius 的像素值(虽然用百分比可以部分解决,但 border-radius: 50% 和 clip-path 的百分比基准不一样,容易出 bug)。
所以我的建议是:简单场景用它,复杂场景老老实实 SVG。加载动画、单色的圆形进度条、不需要精确控制每一段颜色的,这套方案够用了。如果是仪表盘、需要渐变色的、要分段显示的,别硬撑,上 SVG 或者 ECharts 吧。
最后的碎碎念
写到这我看了眼字数,好像快五千了?其实还可以继续水,比如讲讲怎么用 conic-gradient 实现更简单的方案(虽然兼容性差点),或者怎么用 mask-image 搞更骚的操作。但算了,留点头发要紧。
说实话,现在回头看,当年那个暴雨夜让我死磕纯 CSS,可能纯粹是老板不懂技术瞎指挥。但正是那次折磨,让我对 CSS 的 transform、clip-path、overflow 这些属性有了肌肉记忆。有时候前端就是这样,为了省几行 JS 代码,CSS 能写出花来,行数能绕地球一圈。
下次产品再提这种"就要纯 CSS 不要 JS"的需求,我建议你直接把这篇文章甩他脸上,让他看看这玩意儿有多难搞。如果他坚持,那就把预估工时乘以 3,毕竟头发的损失得用加班费来弥补。
如果调了半天还是调不出来,别硬撑。引入个 progressbar.js 或者 nanobar 不丢人,真的。毕竟咱们是解决问题,不是炫技。发际线要紧,心情要紧,下班后的火锅更要紧。
相关免费在线工具
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
- Base64 文件转换器
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
- Markdown转HTML
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
- HTML转Markdown
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
- JSON 压缩
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
- JSON美化和格式化
将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online