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

9 SVG Progress Ring Widgets, Math Included

An SVG progress ring fills a circle instead of a bar, and one <circle> with two attributes gets you there: stroke-dasharray and stroke-dashoffset.

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

The list works up from single dials to rings that carry a second layer inside them, and closes with rings that are compared and repeated. Three concentric rings open, then a single svg progress circle with a number in the middle, then a circle cut into countable slots, then color poured into the stroke itself, then half a circle with a needle on it. After that comes a line drawn inside the ring, a full ring that turns into a check mark, two rings compared side by side, and finally a small ring repeated down a list. The arithmetic is identical in all nine; only the radius changes.

Radius r Circumference C = 2πr Used by
14 87.965 09, the small ring on each list row
28 175.929 01, innermost ring
36 226.195 08, both comparison rings
40 251.327 01, middle ring
44 276.460 03 segments and 07 badge
52 326.726 01 outer ring, plus 02, 04, 06
62 (half) 194.779 05, the half gauge

01Triple activity rings

What you see in this css circular progress bar

Three concentric rings wind to three different values, and "Refill" picks new numbers so the rings and the percentages beside them move together.

Triple activity rings
Open in a new tab

01 · Triple activity rings — Measured area 7.207% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

stroke-dashoffset3 circlesclick

Where to look in the code

Reach for this when one card has to carry three metrics at once, such as steps, movement, and sleep.

.ar__ring--move { stroke-dasharray: 326.726; stroke-dashoffset: calc(326.726 * (1 - var(--p1) / 100)); }
.ar__ring--burn { stroke-dasharray: 251.327; stroke-dashoffset: calc(251.327 * (1 - var(--p2) / 100)); }
.ar__ring--rest { stroke-dasharray: 175.929; stroke-dashoffset: calc(175.929 * (1 - var(--p3) / 100)); }

@keyframes ar-wind {
  0%       { --p1: 0;  --p2: 0;  --p3: 0; }
  45%, 55% { --p1: 82; --p2: 64; --p3: 45; }
  100%     { --p1: 0;  --p2: 0;  --p3: 0; }
}

02Goal ring with count-up number

What happens on screen

The number in the middle ticks up one step at a time while the ring winds.

Goal ring with count-up number
Open in a new tab

02 · Goal ring with count-up number — Measured area 2.951% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

@propertycounter()click

How to read the code

Only --num actually moves, and both the offset and the counter read it, so you never end up with a ring at 60 and a label that says 72.

@property --num {
  syntax: "<integer>";
  inherits: true;
  initial-value: 0;
}
.gr__ring {
  stroke-dasharray: 326.726;
  stroke-dashoffset: calc(326.726 * (1 - var(--num) / 100));
}
.gr__num::after {
  counter-reset: n var(--num);
  content: counter(n) "%";
}

03Segmented step ring

What moves

One circle is cut into eight slots and "Next step" lights one more each time.

Segmented step ring
Open in a new tab

03 · Segmented step ring — Measured area 1.895% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

stroke-dasharrayrotateclick

What to change

Those are not eight arcs cut to length; they are the same full circle repeated eight times, each painting a single dash and then pushed into place by its own offset.

$C: 276.460;     // 2π × 44
$slot: 34.5575;  // C ÷ 8, one slot
$dash: 26;       // painted part. The remaining 8.5575 becomes the gap

.sg__seg {
  stroke-dasharray: $dash ($C - $dash);
  stroke-dashoffset: calc(var(--i) * -#{$slot});
  opacity: .18;
}
.sg__seg.is-on { opacity: 1; }

04Gradient stroke ring

What you see

The stroke itself shifts from sky blue to pink, and dragging the slider fills the ring to that value.

Gradient stroke ring
Open in a new tab

04 · Gradient stroke ring — Measured area 3.247% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

linearGradientinput rangestroke-linecap

Where to look in the code

To get color into a line rather than an area, you point stroke at url(#id) instead of setting fill.

// <linearGradient id="gdStroke" gradientUnits="userSpaceOnUse" x1="12" y1="12" x2="116" y2="116">
.gd__stop-a { stop-color: $color; }        // sky
.gd__stop-b { stop-color: $subject-pink; } // pink
.gd__ring {
  stroke: url(#gdStroke);
  stroke-width: 14;
  stroke-linecap: round;
  stroke-dasharray: 326.726;
  stroke-dashoffset: calc(326.726 * (1 - var(--num) / 100));
}

05Half gauge with needle

What happens on screen

The arc fills while the needle turns to the same angle.

Half gauge with needle
Open in a new tab

05 · Half gauge with needle — Measured area 3.285% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

path arcrotateclick

How to read the code

A half circle is πr long, which is 194.779 at radius 62, and since 0 points at the left end and 100 at the right end, each unit of value is worth exactly 1.8 degrees.

.hg__arc {
  stroke-dasharray: 194.779;   // π × 62, the length of a half circle
  stroke-dashoffset: calc(194.779 * (1 - var(--num) / 100));
}
.hg__needle {
  // 180 degrees split across 100 units is 1.8deg each
  transform: rotate(calc(-90deg + var(--num) * 1.8deg));
  transform-origin: 76px 76px;
}

06Ring with inner sparkline

What moves

The outer ring winds through this month's progress while a seven-day line draws itself from the left inside it.

Ring with inner sparkline
Open in a new tab

06 · Ring with inner sparkline — Measured area 2.05% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

polylinepathLengthclick

What to change

A polyline changes length whenever its points change, so pathLength="100" pins the measured length at 100 and the same dash values keep working no matter how the data moves.

/* <polyline class="sp__line" pathLength="100" points="28,76 40,66 52,80 64,58 76,68 88,50 100,60"/> */
.sp__line { stroke-dasharray: 100; stroke-dashoffset: 0; }
@keyframes sp-draw {
  0%       { stroke-dashoffset: 100; }
  45%, 55% { stroke-dashoffset: 0; }
  100%     { stroke-dashoffset: 100; }
}
// Rotate the rings only. Rotating the sparkline with them would tip it over
.sp__rings { transform: rotate(-90deg); transform-origin: 64px 64px; }

07Ring badge with check morph

What you see

"Mark done" winds the ring to full, then the percentage drops out and a check mark draws itself along its own stroke.

Ring badge with check morph
Open in a new tab

07 · Ring badge with check morph — Measured area 3.041% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

stroke-dashoffsetopacityclick

Where to look in the code

That check is a three-point polyline, not an icon glyph, which is why the same offset trick works on it.

.bg__check {
  stroke-dasharray: 100;
  stroke-dashoffset: 100;
  transition: stroke-dashoffset $dur-enter $easing;
}
.bg.is-done .bg__check { stroke-dashoffset: 0; }
.bg.is-done .bg__num { opacity: 0; }

@keyframes bg-draw {
  0%, 35%   { stroke-dashoffset: 100; }
  50%, 62%  { stroke-dashoffset: 0; }
  72%, 100% { stroke-dashoffset: 100; }
}

08Compare ring pair

What happens on screen

Two rings of the same radius turn the gap between two numbers into a difference in arc, so the eye reads it before the label does. "Next week" pushes this week's value into last week's slot, and the delta between them flips sign and color with it.

Compare ring pair (this vs last week)
Open in a new tab

08 · Compare ring pair (this vs last week) — Measured area 4.356% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

2 ringsdeltaclick

How to read the code

.bp__cell--now .bp__ring  { stroke-dashoffset: calc(226.195 * (1 - var(--a) / 100)); }
.bp__cell--prev .bp__ring { stroke-dashoffset: calc(226.195 * (1 - var(--b) / 100)); }
.bp__delta::after { counter-reset: d var(--d); content: "+" counter(d) "%"; }
.bp.is-down .bp__delta::after { content: "−" counter(d) "%"; }
.bp.is-down .bp__delta { background: $error-soft; color: $error; }
.bp.is-down .bp__ic { transform: scaleY(-1); }

09Ring progress card list

What moves

Three small rings sit on list rows and fill slightly out of step with each other, and clicking a row bumps only that row by 10.

Ring progress card list
Open in a new tab

09 · Ring progress card list — Measured area 0.797% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

r=14animation-delayclick

What to change

Each row has its own target, so each needs its own keyframes, and one SCSS loop writes all three sets.

$goals: 78, 52, 34;

@each $g in $goals {
  $i: index($goals, $g);
  .pl.is-demo .pl__row:nth-child(#{$i}) {
    animation: pl-fill-#{$i} $dur-loop $easing infinite;
    animation-delay: #{($i - 1) * 0.12}s;
  }
  @keyframes pl-fill-#{$i} {
    0%       { --v: 0; }
    45%, 55% { --v: #{$g}; }
    100%     { --v: 0; }
  }
}

Where it breaks — the trap

The worst hour of building these nine went to an unregistered custom property. The first cut of 01 left --p unregistered and wrote calc(var(--c) * (1 - var(--p))) inside the keyframes; the render measurement then reported motion in 2 frames out of 24. An unregistered custom property has no type, so the browser has nothing to interpolate between the two values, and with no in-between the ring simply snapped once at the 45% mark. Declaring it with @property as an <integer> took the same demo to 18 frames. If a ring of yours jumps instead of winding, suspect the value type before anything else. The second thing that catches people is stroke-linecap: round. A round cap overhangs each end by half the stroke width, so a dot stays visible at 0 and the two ends overlap into a slightly thicker band at 100. The third is loop timing. Frames here are captured across a 2 second window, so a 3 second animation leaves the last frame out of step with the first and the loop visibly jumps, which is why every ring winds and unwinds inside a single period and lands back where it started. All three fixes are already baked into the nine files that the archive password zg9hdbsu unlocks, and the React port of each one carries the same registered properties.

Accessibility and reduced-motion

All nine honor prefers-reduced-motion: reduce by dropping the movement and keeping the value. 01, 02, 04, 05, and 08 stop the idle loop and hold a fixed resting value; 03 freezes with five of its eight slots lit. 06, 07, and 09 switch off the looping draw instead, the sparkline and the check mark and the row fills, while leaving the click-driven state change intact. The seven demos with a big center number mark it role="status", and 01 and 06 give the whole ring graphic role="img" with an aria-label, so someone who cannot see the ring still hears the value when it changes. The percentages on screen are printed with counter() from the same variable the ring uses, which means the picture and the announcement can never disagree.

FAQ

How is this different from css radial progress built with conic-gradient?

conic-gradient paints a pie wedge, so turning it into a donut means covering the middle again, and rounding the ends is awkward. An SVG circle with stroke-dashoffset clips a single line instead, which makes thickness, round caps, and stacked rings natural, and the value reduces to one length calculation.

Can I get the circumference from JavaScript instead of doing the math?

Yes. circle.getTotalLength() returns the same number, which means you can change the radius without touching the CSS. These nine use fixed values, so the computed figures went straight into the stylesheet and the script in each demo only hands the ring a new number. The MDN stroke-dasharray reference covers the attribute in full.

My ring starts at 3 o'clock. How do I move it to 12?

Zero degrees on an SVG circle points right. Put transform: rotate(-90deg) on the group that holds the ring and set transform-origin to the circle's center. If something else sits inside the ring, as in 06, it will rotate too, so wrap the rings in their own group first.

Building the screen around these? Dashboard components, 9 of them has the header, kanban, and command palette pieces, and 9 standalone dashboard widgets has the sidebar, table, and empty state. For registering custom properties, the MDN @property page is the shortest explanation.

Enter the archive password

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