9 CSS Progress Bar Circle Gauges — chỉ cần đổi số
CSS progress bar circle là thanh và vòng tiến trình chỉ cần đổi một con số để vẽ lại toàn bộ.
Tự chạy · bấm ô để tới mục đó · cả 9 trong một zip
- 01 Thanh chạy đầy cơ bản
- 02 Sọc chạy animation
- 03 Vòng gauge conic-gradient
- 04 Gauge nửa vòng tròn
- 05 Thanh chia đoạn theo bậc
- 06 Thanh không xác định
- 07 Nhãn phần trăm đồng bộ
- 08 Tiến trình dạng stepper
- 09 Gauge hình pin
Thứ tự chín kiểu này đi theo độ khó của phép tính đứng sau con số, không theo mức phổ biến. 01 và 02 chỉ cần một thanh đổi theo một trục. 03 và 04 chuyển từ hình chữ nhật sang hình tròn — một bên là vòng đầy đủ, một bên chỉ còn nửa vòng có kim chỉ. 05 và 06 bỏ hẳn thanh liền mạch, một bên tách thành từng khối rời, một bên không có giá trị cố định nào cả. 07 gắn thêm một con số di chuyển đúng theo mép phần đã đầy. 08 nối nhiều mốc lại thành một chuỗi bước, còn 09 là kiểu duy nhất đi lùi, cạn dần thay vì đầy lên. Mỗi khối mã dưới đây chỉ giữ phần trực tiếp tạo ra chuyển động, bản đầy đủ nằm trong file zip.
01Thanh chạy đầy cơ bản
--percent được đăng ký qua @property với kiểu number nên trình duyệt nội suy mượt giữa các khung hình, còn phần chạy đầy bên trong không đổi width mà chỉ co giãn theo transform: scaleX(). Đây là kiểu dựng đơn giản nhất trong cả chín kiểu, hợp cho thanh tải file lên hay tiến trình gửi form khi chỉ cần đúng một con số.
@property --percent { syntax: "<number>"; inherits: true; initial-value: 0; }
.track { --percent: 0; position: relative; overflow: hidden; }
.bar {
position: absolute; inset: 0; background: $color;
transform: scaleX(calc(var(--percent) / 100));
transform-origin: left center;
}
.track.is-demo { animation: fill $duration $easing infinite; }
@keyframes fill { 0% { --percent: 0; } 100% { --percent: 76; } }
02Sọc chạy animation
Giá trị đứng yên cố định, chỉ riêng nền sọc chéo tiếp tục trôi qua background-position để không trông như trang đang bị treo giữa chừng. Kiểu này hợp với xử lý dữ liệu hàng loạt hay thanh đồng bộ nền — những việc chạy chậm nhưng vẫn cần một dấu hiệu cho thấy nó vẫn đang chạy.
.bar {
position: absolute; inset: 0;
background-image: repeating-linear-gradient(45deg, $color 0 10px, rgba($color, .55) 10px 20px);
background-size: 28px 28px;
}
.bar.is-demo { animation: stripe-move $duration $easing infinite; }
@keyframes stripe-move {
from { background-position: 0 0; }
to { background-position: 28px 0; }
}
03Vòng gauge conic-gradient
Vòng donut được vẽ bằng conic-gradient, còn góc tô màu tính thẳng từ --percent qua calc() nên không cần vẽ thêm một lớp SVG nào. Ba mốc trong cùng animation đổi màu --zone từ xanh dương sang vàng rồi sang màu mực tối mà không hiện một chữ số nào, để mắt nhận ra trạng thái nhanh hơn đọc số. conic-gradient() đã chạy ổn định trên Chrome, Safari và Firefox từ nhiều năm nay theo bảng hỗ trợ trên caniuse.
@property --percent { syntax: "<number>"; inherits: false; initial-value: 0; }
.ring {
--percent: 0; --zone: #{$color};
background: conic-gradient(var(--zone) calc(var(--percent) * 1%), rgba(255, 255, 255, .35) 0);
}
.ring.is-demo { animation: sweep $duration $easing infinite, zone $duration steps(1) infinite; }
@keyframes zone {
40% { --zone: #{$color}; }
41% { --zone: #{$stage-yellow}; }
75% { --zone: #{$subject-ink}; }
}
04Gauge nửa vòng tròn
Chiều cao khung chỉ bằng nửa chiều rộng, và overflow: hidden cắt bỏ nửa dưới của một vòng tròn đầy đủ nên chỉ nửa trên hiện ra như mặt đồng hồ tốc độ. Kim xoay bằng transform: rotate() theo đúng --percent mà vòng cung phía sau cũng dùng, nên hai thứ luôn khớp nhau vì cùng đọc một giá trị.
@property --percent { syntax: "<number>"; inherits: true; initial-value: 0; }
.gauge { --percent: 0; overflow: hidden; }
.gauge::before {
width: 100%; height: 200%; border-radius: 50%;
background: conic-gradient(from -90deg, $color calc(var(--percent) * 1.8deg), rgba($subject-ink, .12) 0);
}
.gauge__needle {
transform-origin: bottom center;
transform: translateX(-50%) rotate(calc(var(--percent) * 1.8deg - 90deg));
}
05Thanh chia đoạn theo bậc
Track ở đây không phải một thanh liền mà là năm khối riêng, mỗi khối trễ nhau khoảng 160ms qua animation-delay xếp theo nth-child nên chúng sáng lên lần lượt thay vì cùng lúc. Kiểu này hợp khi giá trị vốn đã là bậc thang — cường độ tín hiệu hay số mạng còn lại — chứ không phải một tỉ lệ phần trăm liên tục.
.seg {
width: 16px; height: 46px; border-radius: 4px;
opacity: .35; transform: scaleY(.7); transform-origin: bottom;
}
.segs.is-demo .seg { animation: light 2s $easing infinite; }
.segs.is-demo .seg:nth-child(2) { animation-delay: 160ms; }
@keyframes light {
0%, 60%, 100% { opacity: .35; transform: scaleY(.7); }
20%, 45% { opacity: 1; transform: scaleY(1); background: $color; }
}
06Thanh không xác định
Không có --percent nào ở đây vì giá trị vốn không biết trước; hai đoạn thanh dài ngắn khác nhau chạy lệch nhau khoảng 35% chu kỳ để trông như đang lăn qua nhau, giống thanh loading của Material Design. Dùng khi trang vừa mới tải hoặc đang đợi kết quả tìm kiếm mà chưa có con số nào để hiển thị.
.seg {
position: absolute; inset: 0;
background: $color; transform-origin: left center;
}
.seg--a.is-demo { animation: seg-a $duration $easing infinite; }
.seg--b.is-demo { animation: seg-b $duration $easing infinite; animation-delay: $duration * .35; }
@keyframes seg-a {
0% { transform: translateX(-100%) scaleX(.15); }
55% { transform: translateX(30%) scaleX(.7); }
100% { transform: translateX(120%) scaleX(.15); }
}
07Nhãn phần trăm đồng bộ
--percent được đăng ký kiểu integer vì counter() cần một số nguyên sạch để vẽ ra chữ, còn container-type: inline-size trên track cho phép nhãn bong bóng tính vị trí bằng đơn vị cqw thay vì một mốc phần trăm cố định. Nhãn đó bám đúng mép thanh đang chạy vì dùng chung một --percent với chính thanh chứ không phải một biến riêng.
@property --percent { syntax: "<integer>"; inherits: true; initial-value: 0; }
.track { --percent: 0; container-type: inline-size; }
.bar { transform: scaleX(calc(var(--percent) / 100)); transform-origin: left center; }
.bubble {
position: absolute; top: -30px; left: 0;
transform: translateX(calc(var(--percent) * 1cqw - 50%));
}
.bubble__num { counter-reset: p var(--percent); &::before { content: counter(p) "%"; } }
08Tiến trình dạng stepper
Ba vòng tròn đánh số nối bằng một đường thẳng, và animation-delay xếp chồng theo thứ tự vòng-đường-vòng-đường-vòng nên bước sau chỉ tô màu khi bước trước đã xong. Khác với tám kiểu còn lại, đây không đo một tỉ lệ phần trăm liên tục mà đánh dấu mốc rời rạc, nên mã HTML dùng role="list" thay vì role="progressbar".
.stepper.is-demo .step__dot { animation: dot-fill $duration $easing both; }
.stepper.is-demo .line { animation: line-grow $duration linear both; }
.stepper.is-demo > :nth-child(2) { animation-delay: 400ms; }
.stepper.is-demo > :nth-child(3) .step__dot { animation-delay: 800ms; }
@keyframes dot-fill {
from { background: rgba(255, 255, 255, .14); transform: scale(.85); }
to { background: $color; transform: scale(1); }
}
@keyframes line-grow { from { transform: scaleX(0); } to { transform: scaleX(1); } }
09Gauge hình pin
Phần chạy đầy bên trong khung hình pin co theo scaleX khi --percent giảm dần từ 100 xuống một mức thấp, còn màu tự đổi từ kem sang vàng rồi sang đỏ gạch tại từng ngưỡng trong cùng một @keyframes. Chỉ khi mức đã cạn hẳn, viền pin mới nhấp nháy nhẹ để báo thêm ngoài màu sắc, chứ không nhấp nháy suốt.
@property --percent { syntax: "<number>"; inherits: false; initial-value: 100; }
.battery__fill {
--percent: 100;
transform: scaleX(calc(var(--percent) / 100));
animation: drain $duration $easing infinite;
}
@keyframes drain {
0% { --percent: 100; background: #{$color}; }
55% { --percent: 35; background: #{$stage-yellow}; }
100% { --percent: 8; background: #{$error}; }
}
Chỗ dễ hỏng ở đâu? — cái bẫy
Trong chín kiểu này có hai chỗ thật sự làm nó đứng im nếu chép nhầm. Ở 06, hai đoạn thanh đặt position: absolute nhưng không tự khai left/right, nên một phần tử absolute rỗng mà thiếu inset: 0 thì co về đúng kích thước nội dung của nó — tức 0 — và scaleX khi đó không có gì để giãn ra cả; phải ép inset: 0 phủ hết track thì phần tử mới có chiều rộng thật để transform tác động lên. Ở 04 và 07, chính .gauge và .track là nơi đặt và animate --percent, còn kim với bong bóng là những phần tử con khác đọc lại giá trị đó — theo tài liệu @property của MDN, một custom property chỉ truyền xuống được phần tử con khi đăng ký với inherits: true, đặt nhầm thành false thì con vẫn kẹt ở giá trị ban đầu trong khi cha đã chạy xong việc của nó. Cú pháp @property này chỉ chạy từ Chrome·Edge 85, Safari 16.4 và Firefox 128 trở lên — bảng chi tiết nằm ở caniuse — và trên bản cũ hơn animation bị bỏ qua hoàn toàn nên vòng hay thanh hiện thẳng giá trị cuối chứ không đếm dần lên. Chín file trong gói đã canh đúng inherits cho từng trường hợp, còn mật khẩu để mở file nén là betrch52 và bạn gõ đúng y như nó hiện trên dòng chữ này.
Khả năng tiếp cận
Tám trong chín kiểu — trừ 08 — mang role="progressbar". Bảy kiểu biết trước con số (01–05, 07, 09) đặt đủ aria-valuemin, aria-valuemax và aria-valuenow; riêng 06 chỉ giữ role="progressbar" cùng aria-label, bỏ cả ba thuộc tính giá trị đó vì tiến trình của nó vốn không xác định.
<div class="track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="76" aria-label="Đang tải lên">
<div class="bar is-demo"></div>
</div>
Riêng 08 dùng role="list" cho khối và role="listitem" cho từng bước, vì nó đánh dấu ba mốc rời rạc chứ không phải một thang đo liên tục. Khi bật prefers-reduced-motion: reduce, cả chín kiểu dừng animation và đứng thẳng ở trạng thái cuối — 09 và 03 giữ nguyên màu ứng với mức đang chạy, còn 06 chỉ còn một thanh mờ đứng yên chứ không lăn qua lại nữa. Trang prefers-reduced-motion trên MDN ghi rõ mức hỗ trợ trình duyệt của media query này.
Các kiểu progress bar và gauge khác nằm trong chuyên mục CSS của site, còn lý do mỗi bài đóng gói thành một file zip có khoá thì đọc ở trang giới thiệu.
FAQ
Không dùng @property thì chín gauge này có còn chạy được không?
Chạy được nhưng con số sẽ nhảy thẳng một bước chứ không mượt, vì trình duyệt chỉ nội suy được một custom property khi nó được đăng ký kiểu số qua @property. Muốn giữ hiệu ứng chạy mượt mà không đăng ký kiểu, cách duy nhất còn lại là tự cập nhật giá trị mỗi khung hình bằng requestAnimationFrame trong JavaScript.
Nên chọn gauge nửa vòng (04) hay vòng tròn đầy đủ (03)?
Gauge nửa vòng đọc nhanh hơn khi chỉ có một chỉ số cần theo dõi, vì mắt quen với hình dáng đồng hồ tốc độ. Vòng tròn đầy đủ tiết kiệm không gian hơn và hợp khi cần phân biệt vùng an toàn với vùng cảnh báo bằng màu, như mức dùng tài nguyên hay dung lượng pin — không nên trộn cả hai kiểu trên cùng một màn hình.
Đổi giá trị theo dữ liệu thật từ server thì làm sao?
Chỉ cần gán số mới vào biến --percent qua inline style, không phải viết lại animation nào cả. Vì --percent đã đăng ký kiểu qua @property, thêm một dòng transition vào phần tử là giá trị cũ tự chuyển mượt sang giá trị mới; bản React trong zip nhận percent qua props rồi làm đúng việc đó.