跳到主要内容两个半圆转出圆形进度条:纯CSS方案与踩坑全记录 | 极客日志HTML / CSS大前端
两个半圆转出圆形进度条:纯CSS方案与踩坑全记录
用两个半圆互相遮挡,纯CSS就能做出圆形进度条,不用写一行JS。只要搞清楚左右半圆在0-50%和50-100%时的旋转配合,借助min/max函数自动计算角度,就能实现丝滑动画。文章拆解了HTML结构、关键CSS、空心环做法,还聊了Safari毛边、动态修改闪烁这些坑,以及阴影、mixin封装等实用技巧。最后给了适用场景判断:简单的用它,复杂的上SVG。
极客工坊7 浏览 两个半圆转出圆形进度条:纯CSS方案与踩坑全记录
三年前那个暴雨夜,老板让我用纯CSS搞一个圆形进度条,不要SVG不要Canvas。我起初觉得不难,结果搞到凌晨三点。后来发现,网上大部分教程不是用SVG的stroke-dasharray,就是Canvas画弧,很少有人讲用两个半圆拼圆环的原生CSS办法。今天把这事说清楚——不是为了炫技,就是咽不下这口气,也保证写得比那些'首先我们需要创建一个容器'的AI八股文像人话。
这俩半圆怎么'打架'?
很多人看到圆形进度条就想到三角函数、弧度计算,其实真没那么玄。核心思路土得掉渣:左边藏一个半圆,右边藏一个半圆,让它们旋转互相遮挡,露出的部分就是进度。
想象手里有两个半圆饼干,背靠背拼成一个圆。左边那块不动,右边那块顺时针转。转到180度时,右边刚好盖住左边,进度就是50%。再继续转,右边会跑到左半边去,这时左边的'卧底'要出来接力。这就是为什么纯CSS方案处理0‑50%和50‑100%要用两套逻辑——因为超过180度后,光靠一个半圆转盖不住整个圆,必须左右切换。
角度计算很简单:360度对应100%,1%就是3.6度。显示30%时,右边转30×3.6=108deg就行。70%呢?右边先转到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>
.half-container包裹了两个绝对定位的半圆,.center-mask用来把中间掏空——如果你要实心饼图,这层可以删掉。另外,用CSS自定义属性--percent控制进度,JS只改这一个变量,角度计算全交给CSS,比操作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));
}
你可能对calc里面套min和max有点懵,这是CSS比较函数,2020年后浏览器基本都支持。它的作用就是让CSS自己判断当前进度在哪个区间,该转多少度,完全不用JS插一脚。
--percent: 30 → min(30,50) = 30 → 转108deg,只显示右半边的30%
--percent: 70 → min(70,50) = 50 → 转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,左半圆从隐藏状态转出了72度
空心圆环的秘密:中间挖个洞
.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。如果父容器是背景图,背景色就不能写死,得用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;
}
还有一个坑:旋转方向。进度从100%退回0%时,有时进度条不是直接缩回去,而是顺时针转一大圈(经过360度)回到0度,像抽风一样。这是因为浏览器选了顺时针最短路径,但它不知道你想倒退。解决办法是确保角度计算不出现这种情况,或者在JS里检查新旧值,新值小于旧值就先让旧值减360度,再用transition: none瞬间跳过去再开动画。
真实世界的翻车现场
Safari的1px毛边
Safari上两个半圆接缝处会冒出一条细线,进度接近50%时尤其明显。把右半圆宽度设成calc(50% + 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更新和样式计算有时间差。我的土办法:先关掉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会基于被变换过的坐标系,旋转中心跑偏。要么确保容器没有transform祖先,要么用JS实时计算旋转中心,或者干脆不用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封装,下次直接@include:
@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%绿),这套方案直接歇菜——两个半圆都是单色,换颜色就得加元素,复杂度爆炸;响应式适配也很麻烦,SVG可以viewBox自适应,Canvas能重绘,CSS方案容器大小一变,你得重新算像素值,border-radius:50%和clip-path的百分比基准还不一样,容易出bug。
所以我的建议是:简单场景用它,复杂场景老老实实SVG。加载动画、单色圆形进度条、不需要分段颜色的,这套够用。仪表盘、带渐变色、分段显示的,别硬撑,上SVG或ECharts。
最后碎碎念
写到这里,其实还可以用conic-gradient实现更简单的方案(虽然兼容性差点),或者用mask-image搞更骚的操作,但留点头发要紧。
回头看,当年那个暴雨夜死磕纯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