GODRICH

9 CSS Progress Bar Circle Gauges, Copy-Paste Ready

A css progress bar circle is a ring or arc that fills toward a value with conic-gradient and a custom property instead of a growing box — nine working versions

Auto-plays · click a tile to jump to its section · all nine in one zip

The order below follows what the value itself has to do, not popularity. 01 and 02 fill a straight track — one with a real number behind it, one that only needs to look busy. 03 and 04 bend that same fill into a ring and a half-circle dial. 05 and 06 drop the smooth fill entirely, one stepping through discrete blocks and the other admitting it has no number to show at all. 07 through 09 attach a live label, an ordered multi-step flow, and a color threshold to the mechanic the first six already established. The grid above sits in a small iframe with nothing to click and no server behind it. So every tile loops on a timer through the .is-demo class rather than waiting on a value that would never actually arrive there.

01Linear fill

The fill bar inside the track scales through scaleX rather than animating width, driven by a --percent custom property that's registered as a plain number with @property. That registration is what lets the browser tween smoothly between whatever values it's given. Reach for this one wherever a real number sits behind the bar, like a file upload or a form submission, rather than a stand-in for progress nobody can actually measure.

scaleX@property2초 루프
@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; } }

02Animated stripes

The bar's own width holds still at whatever value it's set to, while a diagonal repeating-linear-gradient behind it scrolls through background-position on a short loop. That keeps something visibly moving even during the long stretches where the real number barely ticks. It fits bulk data jobs and background sync bars, where staring at a frozen number reads as broken even when the job is still running fine.

background-positionrepeating-linear-gradient1초 루프
.bar {
  position: absolute;
  inset: 0;
  width: 62%;
  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; }
}

03Conic-gradient ring

A donut-shaped ring fills to match --percent through conic-gradient, with no digit printed on it anywhere. As the value crosses a low, mid, and high zone, a second --zone property flips the ring's own color from blue to yellow to a dark warning tone. The state then reads at a glance, without anyone having to do the math.

conic-gradient색 구간 전환@property
@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}; }
}

04Half-circle gauge

A needle rotates through transform: rotate() across a dial that only shows the top half of a circle, sweeping from one side to the other the way a speedometer does rather than tracing a full circle. The same --percent value drives both the needle's angle and the conic-gradient arc sitting underneath it, so the two can never fall out of sync with each other.

transform: rotate()반원 clip바늘
@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));
}

05Segmented steps

Instead of one continuous bar, the track splits into five separate blocks, and each lights up in turn as the value climbs, on its own animation-delay rather than all igniting together. It reads closer to a signal-strength meter than a smooth fill, which suits a value that only ever moves in fixed jumps rather than a continuous range.

opacity 단계 전환animation-delay조각 5개
.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; }
.segs.is-demo .seg:nth-child(3) { animation-delay: 320ms; }
@keyframes light {
  0%, 60%, 100% { opacity: .35; transform: scaleY(.7); }
  20%, 45%      { opacity: 1;   transform: scaleY(1); background: $color; }
}

06Indeterminate bar

Rather than a single short bar sweeping back and forth, two segments of different lengths cross the track at staggered timings, passing over each other the way Google's Material Design loading bar does. Reach for it during a wait with no real percentage behind it at all — an initial page load, a search that hasn't returned yet.

두 세그먼트animation-delay 차등cubic-bezier
.track { position: relative; overflow: hidden; }
.seg {
  position: absolute;
  inset: 0;
  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); }
}

07Percent label sync

On a horizontal bar rather than a ring, a bubble label rides the leading edge of the fill and prints the running number itself through counter(). It reads from the identical --percent property that drives the bar's own scaleX. Its horizontal position is calculated in cqw, a container query unit, so the label tracks the bar's own width rather than the viewport's.

translateX 동기말풍선CSS 변수
@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) "%"; }
}

08Stepper progress

Three numbered circles connect through a line, and each one fills only once the step before it has finished — the circle first, then the connecting segment growing through scaleX right after. It fits flows with a real order to them, like a checkout or a signup form, rather than a single running total.

scaleX 연결선원 채움단계 3개
.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); }
}

09Battery-shape gauge

The fill inside a battery-shaped outline scales through scaleX the same way item 01's bar does, but its color shifts from a full-charge tone through yellow down to a warning red as the level drops. All of that runs off the same --percent value across one set of keyframe stops. Below a set point, the whole outline gets a slow, gentle pulse to pull the eye toward it.

scaleX색 임계값 전환테두리 펄스
@property --percent {
  syntax: "<number>";
  inherits: false;
  initial-value: 100;
}
.battery__fill {
  --percent: 100;
  transform-origin: left center;
  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}; }
}
.battery__cell.is-demo { animation: pulse .6s ease-in-out infinite; animation-delay: 1.4s; }

Where it breaks — the trap

Two things actually went wrong while building these nine. The first sits in item 06: the two overlapping segments are positioned absolutely with nothing telling them how wide to be. An absolutely positioned box with no declared width or inset collapses to the size of its own content — an empty span has none, so it shrinks to zero. scaleX() scales that zero right along with it, and nothing shows up no matter how large the transform gets. Setting inset: 0 before any transform touches the segment is what actually gives scaleX() a box worth scaling.

The second trap is inherits on @property, and it only bites items where a parent animates --percent while a different element reads it. Items 04 and 07 each animate the property on a parent — .gauge, .track — while a child, a needle or a label bubble, pulls the same value back out. Both need inherits: true, or that child freezes at the property's initial-value forever, no matter what the parent is doing. Items 03 and 09 apply --percent straight to the element painting with it, so inherits: false is the correct call there instead. Registering the wrong one either way fails without a console warning. MDN's @property reference covers the inherits descriptor directly. The same reference lists Chrome and Edge 85, Safari 16.4, and Firefox 128 as the versions that actually run any of this. Everything below those just paints the final value with no animation at all, since a custom property with unregistered syntax can't be interpolated. conic-gradient() itself needs far less: caniuse's conic-gradient table puts support at Chrome 69, Safari 12.1, and Firefox 83. Every file in the zip already has the right inherits value picked for its own structure, unlocked with betrch52 entered precisely as it's printed in this paragraph, capitalization and all.

Accessibility

All nine except 08 carry role="progressbar", but they don't all carry the same value attributes with it. 01 through 05, 07, and 09 add aria-valuenow because a real number sits behind each of them — 05's is just capped with aria-valuemax="5" instead of 100, since five lit blocks is genuinely what "done" means there, not a percentage. Item 06 has the role and an aria-label alone, with none of the three value attributes at all — the WAI-ARIA authoring practices allow dropping aria-valuenow, and by extension the min/max pair, on a progressbar with no determinate value to report, rather than inventing a number nobody actually has. Item 08 skips the progressbar role entirely: three named steps aren't one running value, so it's marked role="list" with role="listitem" on each stop instead.

<div class="track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="76" aria-label="Uploading">
  <div class="bar"></div>
</div>

Under prefers-reduced-motion: reduce, every item that fills toward a value jumps straight to that value instead of easing there. 01's bar sits at 76% with no sweep, and 03's ring shows its final zone color with no color transition along the way. Item 06 is the one exception worth a second look: because it never had a real value to jump to, reduced motion just stops the two segments in place. That leaves "no motion" and "no progress shown" looking identical there. MDN's prefers-reduced-motion page documents the operating-system settings behind that media query.

The rest of this project's loading and status demos are gathered at the CSS catalog, while the about section covers what this project actually is and isn't.

FAQ

Does a css progress bar circle need JavaScript to update?

No, not for the fill itself. 01, 03, 04, 07, and 09 drive their value through a @property-registered --percent custom property animated with @keyframes, so the browser interpolates between values on its own. The rest — 02's stripes, 05's segments, 06's overlapping bars, and 08's stepper — reach the same look with plain @keyframes and fixed values instead, no custom property involved. Wiring any of them to something real — an upload's actual byte count, a live sensor reading — still needs a script from outside, but nothing about the animation itself depends on one.

Why does the ring in item 03 change color instead of just filling further?

Because the value it represents isn't meant to be read as a precise number in the first place — it's a resource gauge where "how close to the danger zone" matters more than the exact figure. Swapping the ring's own color at fixed thresholds through a second --zone property lets someone glance at it and register "fine," "watch it," or "act now" without stopping to read a digit at all.

Can I make a half-circle gauge like item 04 go past 180 degrees?

Yes — the 1.8deg multiplier in both the needle's rotation and the conic-gradient's angle is what maps 0–100 onto exactly 180 degrees. Raising that number to, say, 2.4deg stretches the same dial across 240 degrees instead. The container's height: 200% trick that crops a full circle down to a visible arc works the same way regardless of how much of that circle the gauge actually uses.

Enter the archive password

The password is inside this article. You will find it as you read.