GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

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, a css progress bar with steps, 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. When there is no number to wait on at all, CSS loading spinners admit that more honestly than item 06 does, and a chat screen waiting on a reply is better served by AI chat loading animations.

01Linear fill

How it works

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.

Where it fits

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.

Linear fill
Open in a new tab

01 · Linear fill — Measured area 2.051% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

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

How it works

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.

Where it fits

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.

Animated stripes
Open in a new tab

02 · Animated stripes — Measured area 1.883% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

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

How the css circle progress bar animation fills

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.

Where it fits

The state then reads at a glance, without anyone having to do the math.

Conic-gradient ring
Open in a new tab

03 · Conic-gradient ring — Measured area 3.458% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

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

How it works

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.

Where it fits

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.

Half-circle gauge
Open in a new tab

04 · Half-circle gauge — Measured area 7.955% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

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

How it works

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.

Where it fits

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.

Segmented steps
Open in a new tab

05 · Segmented steps — Measured area 2.794% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

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

How it works

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.

Where it fits

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.

Indeterminate bar
Open in a new tab

06 · Indeterminate bar — Measured area 1.542% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password

두 세그먼트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

How it works

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.

Where it fits

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.

Percent label sync
Open in a new tab

07 · Percent label sync — Measured area 7.601% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password

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

How it works

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.

Where it fits

It fits flows with a real order to them, like a checkout or a signup form, rather than a single running total.

Stepper progress
Open in a new tab

08 · Stepper progress — Measured area 2.911% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

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

How it works

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.

Where it fits

Below a set point, the whole outline gets a slow, gentle pulse to pull the eye toward it.

Battery-shape gauge
Open in a new tab

09 · Battery-shape gauge — Measured area 1.205% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

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.

A box with no width

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.

Which way inherits goes

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.

That same parent-animates-it, child-reads-it split turns up in CSS tab switch animations, where a sliding underline has to read a position the tab list owns rather than one of its own.

What's in the zip

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

What each one reports

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>

Reduced motion

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 css progress circle animation in item 03 change color instead of 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. When the exact figure does have to be legible, the SVG progress ring widgets carry item 03's shape over to a stroke-dasharray ring that counts the number up beside it.

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.