Creating a round progress indicator of any size using CSS and SVG
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 shapeThe
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}