GODRICH

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 ring 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

Three concentric rings wind to three different values, and "Refill" picks new numbers so the rings and the percentages beside them move together. Reach for this when one card has to carry three metrics at once, such as steps, movement, and sleep.

stroke-dashoffset3 circlesclick
.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

The number in the middle ticks up one step at a time while the ring winds. 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.

@propertycounter()click
@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

One circle is cut into eight slots and "Next step" lights one more each time. 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.

stroke-dasharrayrotateclick
$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

The stroke itself shifts from sky blue to pink, and dragging the slider fills the ring to that value. To get color into a line rather than an area, you point stroke at url(#id) instead of setting fill.

linearGradientinput rangestroke-linecap
// <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

The arc fills while the needle turns to the same angle. 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.

path arcrotateclick
.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

The outer ring winds through this month's progress while a seven-day line draws itself from the left inside it. 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.

polylinepathLengthclick
/* <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

"Mark done" winds the ring to full, then the percentage drops out and a check mark draws itself along its own stroke. That check is a three-point polyline, not an icon glyph, which is why the same offset trick works on it.

stroke-dashoffsetopacityclick
.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

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.

2 ringsdeltaclick
.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

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. Each row has its own target, so each needs its own keyframes, and one SCSS loop writes all three sets.

r=14animation-delayclick
$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 a conic-gradient progress circle?

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.