> home / blog /

Creating a round progress indicator of any size using CSS and SVG

December 10, 2024|cssJavascriptreact

Lately I faced the challenge to create a circular progress indicator, similar to the one below:

I wanted to do this with as little Javascript as possible. However, there are some things to consider.

Idea: SVG Circles

The basic Idea was to use two SVG circle elements - one for the grey background, one for the green progress indicator. However, I wanted the progress indicator to be able to display any kind of progress, so I didn’t want to use hardcoded SVG Paths.

The idea was to use a combination of stroke-dasharray and stroke-dashoffset instead. On the Mdn Docs it says:

The stroke-dasharray attribute is a presentation attribute defining the pattern of dashes and gaps used to paint the outline of the shape

The stroke-dashoffset attribute is a presentation attribute defining an offset on the rendering of the associated dash array.

In other words: Imagine a circle with a dashed border. stroke-dasharray is specifying the length of each of the dashes in the border, stroke-dashoffset is specifying how far apart the dashes should be.

So if we find a way to use the correct values for stroke-dasharray and stroke-dashoffset, we could create a pattern with a dash which is exactly as long as the circumference of the whole circle, but adjust the offset in a way so that only the part of the dash is shown which matches the progress we want to display.

Step 1: Using a circle with a fixed width

This sounds pretty straightforward and it indeed is, if you use a circle with a special width:

1<svg
2  width="100"
3  height="100"
4  viewBox="0 0 100 100"
5  fill="gray"
6  xmlns="http://www.w3.org/2000/svg"
7>
8	<!-- background -->
9  <circle
10    r="15.915"
11    cx="50"
12    cy="50"
13    fill="transparent"
14    stroke="darkgrey"
15    stroke-width="4"
16  ></circle>
17
18  <circle
19    id="progress-bar"
20    r="stroke-dashoffset"
21    cx="50"
22    cy="50"
23    fill="transparent"
24    stroke="green"
25    stroke-width="4"
26    stroke-linecap="round"
27    stroke-dasharray="100"
28    stroke-dashoffset="66"
29  ></circle>
30</svg>

In this case, the calculation of the stroke-dasharray and stroke-dashoffset properties are very easy: stroke-dasharray is always going to be 100, stroke-dashoffset is going to be 100 - <the percentage you want to display>.

Why does this work?

This works, because the value I chose for the radius of the circle is exactly 15.915. With this value, the circle will have a circumference of 100 (15.915 * π * 2 = 100). If the circumference of your circle is 100, and the dashes on the border are also 100 units long but have an offset of (100 - <the percentage you want to display>) units, the part of the dash shown will exactly match the percentage you would want to display.

Step 2: Any Circle

But what happens when you change the radius of the circle? Keeping all other values, only changing the radius to 25 and setting the desired percentage to 50 results in the following:

As you can see, the progress indicator is not at all at 50% like you would expect.

So, what is happening?

Changing the radius of the circle changed the circumference. Instead of being 100, the circumference is now 25 * π * 2 = 157. However, the dashes still have a length of 100 and a gap of 50 units. 50 / 150 = 0.33, which is why the progress bar looks like it’s only filled one third of the way.

So to fix this, we need to dynamically calculate the values for stroke-dasharray and stroke-dashoffset based of the circles radius.

Calculating the stroke-dasharray value:

Calculating the stroke-dasharray is easy, since it should always be equal to the circumference of the circle:

1const calculateDasharray = (r: number): number => {
2    return Math.PI * r * 2;
3}

Calculating the stroke-dashoffset value:

To calculate the stroke-dashoffset, we need to use the stroke-dasharray aka the circumference of the circle:

1const calculateDashoffset = (
2    percentageShown: number,
3    circumference: number
4  ): number => {
5    return ((100 - percentageShown) / 100) * circumference;
6  };

Putting it all together

If you know put it all together, you will end up with a nice circular progress bar, which supports any size you want and any percentage you want to show. You can see it live in action in the following stackblitz:

Alternatively, this is the code I used:

1import * as React from 'react';
2import { useState } from 'react';
3import './style.css';
4
5export default function App() {
6  const [radius, setRadius] = useState(25);
7  const [percentage, setPercentage] = useState(66);
8  const [dashArray, setDashArray] = useState(157.07963267948966);
9  const [dashOffset, setDashOffset] = useState(53.40707511102649);
10
11  const onRadiusChange = (event) => {
12    const newRadius = event.target.value;
13
14    const newDashArray = calculateDasharray(newRadius);
15    const newDashOffset = calculateDashoffset(percentage, newDashArray);
16
17    console.log(newDashArray);
18    console.log(newDashOffset);
19
20    setRadius(newRadius);
21    setDashArray(newDashArray);
22    setDashOffset(newDashOffset);
23  };
24
25  const onPercentageChange = (event) => {
26    const newPercentage = event.target.value;
27
28    const dashOffset = calculateDashoffset(newPercentage, dashArray);
29
30    setPercentage(newPercentage);
31    setDashArray(dashArray);
32    setDashOffset(dashOffset);
33  };
34
35  const calculateDasharray = (r: number): number => {
36    return Math.PI * r * 2;
37  };
38
39  const calculateDashoffset = (
40    percentageShown: number,
41    circumference: number
42  ): number => {
43    return ((100 - percentageShown) / 100) * circumference;
44  };
45
46  return (
47    <div>
48      <svg
49        width="500"
50        height="500"
51        viewBox="0 0 500 500"
52        fill="gray"
53        xmlns="http://www.w3.org/2000/svg"
54      >
55        {/** background circle */}
56        <circle
57          r={radius}
58          cx="250"
59          cy="250"
60          fill="transparent"
61          stroke="darkgrey"
62          stroke-width="4"
63        ></circle>
64
65        {/** progress bar circle */}
66        <circle
67          id="progress-bar"
68          cx="250"
69          cy="250"
70          fill="transparent"
71          stroke="green"
72          stroke-width="4"
73          stroke-linecap="round"
74          r={radius}
75          stroke-dasharray={dashArray}
76          stroke-dashoffset={dashOffset}
77        ></circle>
78      </svg>
79      <div>
80        <label>Circle Radius: </label>
81        <input
82          type="number"
83          onChange={onRadiusChange}
84          min="1"
85          max="250"
86          value={radius}
87        ></input>
88      </div>
89      <br />
90      <div>
91        <label>Percentage: </label>
92        <input
93          type="number"
94          onChange={onPercentageChange}
95          min="0"
96          max="100"
97          value={percentage}
98        ></input>
99      </div>
100    </div>
101  );
102}