CSS Donut Chart

SVG donut chart animated with stroke-dashoffset, with no JavaScript required.

Published August 2, 2026 Advanced 9 min read

An animated CSS donut chart is an SVG circle with no fill and a thick stroke, where two properties decide how much of that stroke is painted. stroke-dasharray turns the ring into one long dash and stroke-dashoffset slides it out of view, so animating the offset draws the arc in. The percentage and the offset are related by one line of arithmetic, and everything else is styling.

The three rings below draw on a loop with a hold, for a reason explained in the fourth paragraph. Two more builds follow: a single ring split into four colored segments, and a 270 degree gauge with the gap at the bottom. For the same shape drawn with a gradient rather than a stroke, see the CSS pie chart.

Three rings, drawn on a loop

75%
CSS
60%
JS
88%
HTML

HTML

<div class="donuts">
  <div class="donut-wrap">
    <div class="donut-center">
      <svg class="donut-svg" width="100" height="100" viewBox="0 0 80 80">
        <circle class="donut-track" cx="40" cy="40" r="35"/>
        <circle class="donut-fill donut-fill-green" cx="40" cy="40" r="35"/>
      </svg>
      <div class="donut-label-wrap">
        <span class="donut-pct pct-green">75%</span>
      </div>
    </div>
    <span class="donut-name">CSS</span>
  </div>

  <div class="donut-wrap">
    <div class="donut-center">
      <svg class="donut-svg" width="100" height="100" viewBox="0 0 80 80">
        <circle class="donut-track" cx="40" cy="40" r="35"/>
        <circle class="donut-fill donut-fill-sky" cx="40" cy="40" r="35"/>
      </svg>
      <div class="donut-label-wrap">
        <span class="donut-pct pct-sky">60%</span>
      </div>
    </div>
    <span class="donut-name">JS</span>
  </div>

  <div class="donut-wrap">
    <div class="donut-center">
      <svg class="donut-svg" width="100" height="100" viewBox="0 0 80 80">
        <circle class="donut-track" cx="40" cy="40" r="35"/>
        <circle class="donut-fill donut-fill-coral" cx="40" cy="40" r="35"/>
      </svg>
      <div class="donut-label-wrap">
        <span class="donut-pct pct-coral">88%</span>
      </div>
    </div>
    <span class="donut-name">HTML</span>
  </div>
</div>

CSS

.donuts {
  display: flex;
  gap: 1.5rem;
  align-items: center;
  justify-content: center;
  flex-wrap: wrap;
}

.donut-wrap {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: .5rem;
}

/* rotate the whole SVG so the arc starts at 12 o'clock */
.donut-svg {
  transform: rotate(-90deg);
  overflow: visible;
}

.donut-track {
  fill: none;
  stroke: #1c1c1e;
  stroke-width: 10;
}

.donut-fill {
  fill: none;
  stroke-width: 10;
  stroke-linecap: round;
  /* circumference of r=35: 2π x 35 = 220 */
  stroke-dasharray: 220;
  /* --arc is how much of the 220 circumference stays hidden, so a smaller
     number draws a longer arc. Each ring overrides it below with a plain
     number: a length will not interpolate against an unresolved calc(), and
     the arc would snap to its end value instead of drawing in. */
  --arc: 220;
  stroke-dashoffset: var(--arc);
  animation: donut-draw 4.5s cubic-bezier(0.16, 1, 0.3, 1) .2s infinite;
}

/* offset = circumference x (1 - percentage) */
.donut-fill-green { stroke: #b8ff57; --arc: 55; }   /* 220 * (1 - .75) */
.donut-fill-sky   { stroke: #38bdf8; --arc: 88; }   /* 220 * (1 - .60) */
.donut-fill-coral { stroke: #ff6b6b; --arc: 26.4; } /* 220 * (1 - .88) */

/* the label sits over the un-rotated wrapper, so it stays upright */
.donut-center {
  position: relative;
}

.donut-label-wrap {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  text-align: center;
}

.donut-pct {
  display: block;
  font-size: 1.3rem;
  font-weight: 700;
  font-family: "DM Mono", "Fira Code", Consolas, monospace;
  line-height: 1;
}

.pct-green { color: #b8ff57; }
.pct-sky   { color: #38bdf8; }
.pct-coral { color: #ff6b6b; }

.donut-name {
  font-size: .8rem;
  font-weight: 500;
  color: #88888f;
}

/* Looped so the draw-in is still visible to someone who arrives late. */
@keyframes donut-draw {
  0%   { stroke-dashoffset: 220; }
  30%  { stroke-dashoffset: var(--arc); }
  85%  { stroke-dashoffset: var(--arc); }
  100% { stroke-dashoffset: 220; }
}

Other ways to build it

One ring, four segments

Giving stroke-dasharray two values instead of one turns it from a single long dash into a segment followed by a gap, and that is all a multi category ring needs. Each circle here paints one segment: the first number is that segment's share of the 220 unit circumference, the second is large enough that the pattern never repeats, and a negative stroke-dashoffset equal to the total length of everything before it slides the segment into its place around the ring. Three units are shaved off each dash to leave a visible gap between neighbours, and the caps are butt rather than round, because round caps would extend each segment back into the gap it just left.

2,480
Requests by type
  • Scripts40%
  • Images30%
  • Styles18%
  • Fonts12%

HTML

<svg class="donut-svg" width="130" height="130" viewBox="0 0 80 80" aria-hidden="true">
  <circle class="donut-track" cx="40" cy="40" r="35"/>
  <circle class="dm-seg dm-seg-1" cx="40" cy="40" r="35"/>
  <circle class="dm-seg dm-seg-2" cx="40" cy="40" r="35"/>
  <circle class="dm-seg dm-seg-3" cx="40" cy="40" r="35"/>
  <circle class="dm-seg dm-seg-4" cx="40" cy="40" r="35"/>
</svg>

CSS

.dm-seg {
  fill: none;
  stroke-width: 10;
  /* butt, not round: round caps would push each segment back
     into the gap it was given */
  stroke-linecap: butt;
}

/* first value  = this segment's share of the 220 unit circumference,
                  minus 3 units to leave a gap
   second value = larger than the circle, so nothing repeats
   offset       = minus the total length of every segment before it */
.dm-seg-1 {
  stroke: #b8ff57;
  stroke-dasharray: 85 220;    /* 40% of 220 = 88 */
  stroke-dashoffset: 0;
}

.dm-seg-2 {
  stroke: #38bdf8;
  stroke-dasharray: 63 220;    /* 30% of 220 = 66 */
  stroke-dashoffset: -88;
}

.dm-seg-3 {
  stroke: #c084fc;
  stroke-dasharray: 36.6 220;  /* 18% of 220 = 39.6 */
  stroke-dashoffset: -154;
}

.dm-seg-4 {
  stroke: #ffd444;
  stroke-dasharray: 23.4 220;  /* 12% of 220 = 26.4 */
  stroke-dashoffset: -193.6;
}

A 270 degree gauge

A dial with a gap at the bottom reads as a meter rather than as a share of a whole, which suits a single value with a floor and a ceiling. The track is stroke-dasharray: 165 220, since 165 units is three quarters of the circumference, and the SVG is rotated 135 degrees to put the missing quarter at the bottom instead of the top right. The fill covers the same 165 units, so a 68% reading needs an offset of 220 - 165 x 0.68, which is 107.8. These three reuse the donut-draw keyframes from the chart at the top of the page without a single change, because the movement is identical and only the geometry underneath it has moved.

68%
Storage
45%
Bandwidth
90%
Build minutes

HTML

<div class="donuts">
  <div class="donut-wrap">
    <div class="donut-center">
      <svg class="dg-svg" width="120" height="120" viewBox="0 0 80 80" aria-hidden="true">
        <circle class="dg-track" cx="40" cy="40" r="35"/>
        <circle class="dg-fill dg-fill-a" cx="40" cy="40" r="35"/>
      </svg>
      <div class="donut-label-wrap"><span class="donut-pct dg-pct-a">68%</span></div>
    </div>
    <span class="donut-name">Storage</span>
  </div>
  <div class="donut-wrap">
    <div class="donut-center">
      <svg class="dg-svg" width="120" height="120" viewBox="0 0 80 80" aria-hidden="true">
        <circle class="dg-track" cx="40" cy="40" r="35"/>
        <circle class="dg-fill dg-fill-b" cx="40" cy="40" r="35"/>
      </svg>
      <div class="donut-label-wrap"><span class="donut-pct dg-pct-b">45%</span></div>
    </div>
    <span class="donut-name">Bandwidth</span>
  </div>
  <div class="donut-wrap">
    <div class="donut-center">
      <svg class="dg-svg" width="120" height="120" viewBox="0 0 80 80" aria-hidden="true">
        <circle class="dg-track" cx="40" cy="40" r="35"/>
        <circle class="dg-fill dg-fill-c" cx="40" cy="40" r="35"/>
      </svg>
      <div class="donut-label-wrap"><span class="donut-pct dg-pct-c">90%</span></div>
    </div>
    <span class="donut-name">Build minutes</span>
  </div>
</div>

CSS

/* uses @keyframes donut-draw from the main example above; the rules here are
   what changes, not the whole file */
/* 135deg, not -90deg: this puts the missing quarter at the bottom */
.dg-svg {
  transform: rotate(135deg);
  overflow: visible;
}

/* 165 of the 220 unit circumference is three quarters of the ring */
.dg-track {
  fill: none;
  stroke: #1c1c1e;
  stroke-width: 10;
  stroke-dasharray: 165 220;
  stroke-linecap: round;
}

.dg-fill {
  fill: none;
  stroke-width: 10;
  stroke-linecap: round;
  stroke-dasharray: 220;
  --arc: 220;
  stroke-dashoffset: var(--arc);
  animation: donut-draw 4.5s cubic-bezier(0.16, 1, 0.3, 1) .2s infinite;
}

/* offset = 220 - 165 x percentage, worked out rather than calculated
   in the declaration, so the value can interpolate */
.dg-fill-a { stroke: #b8ff57; --arc: 107.8; }  /* 68% */
.dg-fill-b { stroke: #38bdf8; --arc: 145.75; } /* 45% */
.dg-fill-c { stroke: #ff6b6b; --arc: 71.5; }   /* 90% */

@media (prefers-reduced-motion: reduce) {
  .dg-fill {
    animation: none;
    stroke-dashoffset: var(--arc);
  }
}

How it works

An SVG <circle> with fill: none and a stroke becomes a ring. stroke-dasharray sets the dash length equal to the circle's circumference, which is 2 x pi x r. stroke-dashoffset shifts that dash: at an offset equal to the circumference the stroke is entirely out of view, and at 0 the full ring is painted. Animating from the full offset to a target value draws the arc. The offset for a given percentage is circumference x (1 - percentage), and a rotate(-90deg) on the SVG moves the starting point to the top.

The geometry comes first. A circle of radius 35 has a circumference of 2 x pi x 35, which is 219.9, and 220 is close enough that the last tenth of a percent never shows. Setting stroke-dasharray: 220 makes the dash and the gap both 220 units long, so the entire ring is one dash and the pattern never repeats. stroke-dashoffset: 220 slides that dash a full length backwards, leaving the gap covering the whole circle and the ring invisible. Every value between those two extremes is a partial arc.

Converting a percentage into an offset is circumference x (1 - percentage). Seventy five percent gives 220 x 0.25, which is 55, so a ring that should be three quarters full has 55 units of its circumference still hidden. It reads backwards the first time: a smaller number draws a longer arc. Each ring in the demo carries its own --arc value, and the shared rule reads it in the keyframes so one animation serves all three.

Those --arc values are plain numbers rather than calc() expressions, and that is not a style preference. Writing --arc: calc(220 * (1 - .75)) looks tidier and breaks the animation: a CSS length will not interpolate against an unresolved calc(), so instead of drawing in, the arc snaps straight to its end value on the first frame and sits there. This one cost real time to track down here, because nothing errors and the finished state looks correct. Keep the arithmetic out of the animated value and put the result in.

The animation loops with a hold rather than playing once, and the reason is the same one that shaped the CSS bar chart. A one shot draw finishes about a second after the page loads, long before most readers have scrolled far enough to see the rings, so what they find is a static ring and no sense that anything was drawn. The keyframes reach the target at 30% of the cycle and hold it until 85%, which means the chart spends more than half of every 4.5 seconds showing its real value.

rotate(-90deg) on the <svg> is what moves the start of the arc from three o'clock to twelve. Because the rotation is applied to the whole element, anything inside it turns too, which is why the percentage label lives in a separate wrapper outside the SVG rather than in an SVG <text> node. stroke-linecap: round adds half a stroke width to each end of the arc, which looks better and makes the arc read very slightly longer than its true value. On a chart where the number matters, use butt caps and let the label carry the precision.

CSS properties used

fill
none on the circle. Without it the circle is painted solid black and the ring disappears inside it.
stroke-width
The thickness of the ring. It grows outward and inward from the path, so a 10 unit stroke on a radius 35 circle needs 5 units of room inside the viewBox.
stroke-dasharray
Set to the circumference so the whole ring is one dash. Given two values it becomes a segment and a gap, which is how the multi segment variant works.
stroke-dashoffset
The only animated property. Equal to the circumference the ring is invisible; at 0 it is complete. Negative values shift the dash forward along the path.
stroke-linecap
round softens the ends of the arc and lengthens it by half a stroke width at each end. butt is the honest choice when the value is being read rather than glanced at.
transform
rotate(-90deg) on the SVG starts the arc at the top. It rotates everything inside, so labels belong in a wrapper outside the SVG.
custom properties
One --arc per ring, holding the target offset as a plain number. A calc() here stops the value interpolating and the arc snaps instead of drawing.

Browser support

FeatureChromeFirefoxSafariEdge
SVG433.212
animation455.112
transform43.53.112
custom properties49311016
prefers-reduced-motion746310.179

Figures come from Can I Use. stroke-dasharray and stroke-dashoffset are SVG properties with no separate entry, so they take the SVG row. The one thing with no entry at all is @property, which is the supported way to register a custom property as a <number> so it can be interpolated. That would remove the restriction described above and let the offset be computed rather than written out. Because it has no Can I Use record here, no version is claimed for it, and the plain number approach works everywhere.

Accessibility notes

An SVG ring communicates nothing to a screen reader on its own. The percentage in the demo is real text in a <span> sitting over the ring, which is why it is announced at all. Keep it that way rather than moving the number into an SVG <text> node, and give the <svg> aria-hidden="true" so the graphic is not announced twice. If the ring has no visible label, put one in with role="img" and an aria-label describing the value.

A looping draw is still motion. It holds still for most of its cycle, which makes it far less intrusive than a continuous spin, but under prefers-reduced-motion: reduce the right answer is to stop the animation and leave the ring at its final value. The reader loses nothing, because the final value is the information.

The ring and its track need to be distinguishable from each other and from the page. A graphical object like this is held to a 3:1 contrast ratio rather than the 4.5:1 that applies to text, and a thin track in a near background color will fail it. Color alone should also not be the only thing separating one ring from another when several sit side by side, which is what the labels underneath are for.

What you can build with it

  • Completion and progress indicators. A single ring showing how far through a task or a profile someone is, with the number in the middle.
  • Score and rating displays. Three or four rings in a row for a page speed report or a skills breakdown, each with its own color and label.
  • Quota and storage meters. Used against available, where the gauge variant below reads more like a physical dial than a full circle does.
  • Multi category breakdowns. One ring split into segments, which is the first variant and the direct alternative to a pie chart.
  • Dashboard tiles. Small rings across a grid of metrics, where the loop with a hold means a reader who scrolls down later still sees them draw.

Mistakes worth avoiding

  • Putting a calc() in the animated custom property. The value will not interpolate against an unresolved expression, so the arc jumps to its end state on the first frame with no error anywhere.
  • Forgetting fill: none. The circle fills with black, covers the middle of the chart and hides the label, which looks like the label failed to render rather than like a fill problem.
  • Sizing the viewBox to the radius alone. The stroke straddles the path, so half of it falls outside the circle and gets clipped at the edge of the viewBox unless there is room for it or overflow: visible is set.
  • Rotating the SVG with the label inside it. The number ends up on its side. Keep the label in a wrapper that is not rotated and position it over the ring.
  • Playing the draw once on page load. It is over before anyone scrolls to it, so almost every reader sees a static ring. Loop it with a hold at the target value.

Frequently asked questions

How do you calculate stroke-dashoffset for a percentage?
Multiply the circumference by one minus the percentage. For a circle of radius 35 the circumference is about 220, so 75% gives 220 x 0.25, which is 55. A smaller offset means more of the ring is drawn.
Why does my donut chart snap instead of animating?
The target value is a calc() expression. A CSS length does not interpolate against an unresolved calculation, so the animation jumps straight to the end. Work the number out and write the result in, or register the property with @property so it has a type.
How do I start the arc at the top instead of the right?
Add transform: rotate(-90deg) to the <svg>. SVG paths start at three o'clock by default. Anything inside the SVG rotates with it, so put labels in a separate wrapper.
Can one ring show several categories?
Yes. Stack one circle per segment, give each stroke-dasharray: <segment length> <circumference> so only its own piece is drawn, and use a negative stroke-dashoffset equal to the total length of the segments before it. The first variant on this page does exactly that.
Is an SVG donut better than a conic-gradient one?
For anything that animates, yes. stroke-dashoffset is a plain number that interpolates, while a gradient stop position does not without @property. SVG also gives you a real hole rather than a disc of background color, and each segment is a separate element you can hover or label.