Skip to content

Home

How can I create a circular progress bar using only CSS?

Circular progress bars are a fairly common element in today's websites. Yet, to many developers, they seem like quite the intimidating challenge. The truth of the matter is that getting the basics right is not that hard. In fact, with the help of some new CSS features, it's easier than ever.

Anatomy of a circular progress bar

A circular progress bar is, at the simplest, two circles stacked on top of each other. The bottom circle is the background, and the top circle is the progress indicator. We'll get to how we fill the progress indicator in a bit, but the basic structure can be easily built using an <svg> element and a little bit of CSS.

<svg width="250" height="250" viewBox="0 0 250 250">
  <circle class="bg"
    cx="125" cy="125" r="115" fill="none" stroke="#ddd" stroke-width="20"
  ></circle>
  <circle class="fg"
    cx="125" cy="125" r="115" fill="none" stroke="#5394fd" stroke-width="20"
  ></circle>
</svg>
circle.fg {
  transform: rotate(-90deg);
  transform-origin: 125px 125px;
}

As you can see, the only piece of CSS we need to get the basic structure right is a transform property. We rotate the foreground circle by 90 degrees, and we set the transform-origin to the center of the circle. This way, the circle is rotated around its center, and the progress indicator starts at the top.

Doing the math

Before we move forward, we might as well take a moment to understand the math behind the code.

The two values we need to decide are the size of the progress bar and the width of the stroke. For this example, we settled on a size of 250px and a stroke width of 20px. We'll use these values to calculate the rest of the values we need.

The size is use to set the width and height attributes of the SVG element, as well as the viewBox attribute. Dividing it by two, we get 125px, which corresponds to the coordinates of the center of the image. This value is used to set the cx and cy attributes of the circles.

Taking into account the stroke width, we can calculate the radius of the circle. The radius is the distance from the center of the circle to the edge. In this case, the radius is 115px, which is the size of the image, minus the stroke width, divided by two.

Finally, we can calculate the circumference of the circle. The circumference is the length of the edge of the circle. In this case, the circumference is 722.5px, which is 2 * π * 115px.

Variable Value Formula
size 250px N/A (user defined)
stroke 20px N/A (user defined)
center 125px size / 2
radius 115px (size - stroke) / 2
circumference 722.5px 2 * π * radius

These numbers will start coming in handy as we move forward, but I promise this is almost all the math we'll need to do.

Filling the progress indicator

Now that we have the basic structure in place, we need to fill the progress indicator. To do this, we'll use the stroke-dasharray property, which takes alternating values for lengths and dashes.

To create a progress bar, we want to pass two values: the length of the filled part, and the length of the empty part. To get the filled part we multiply the progress percentage by the circumference of the circle. To get the empty part, we subtract the filled part from the circumference.

Supposing we want to fill 50% of the circle, the SVG code would look like this:

<svg width="250" height="250" viewBox="0 0 250 250">
  <circle class="bg"
    cx="125" cy="125" r="115" fill="none" stroke="#ddd" stroke-width="20"
  ></circle>
  <circle class="fg"
    cx="125" cy="125" r="115" fill="none" stroke="#5394fd" stroke-width="20"
    stroke-dasharray="361.25 361.25"
  ></circle>

Making the progress bar dynamic

Hardcoding the stroke-dasharray value is not very useful. We want to be able to set the progress percentage dynamically. This is where CSS variables and the math from before come into play.

Given a --progress variable, we can calculate the stroke-dasharray relatively easily. Knowing we will need most of the values from before, we can set them as CSS variables, too. Even better, most of the SVG attributes we want to set can be manipulated using CSS.

Here's what the restructured code looks like:

<svg
  width="250" height="250" viewBox="0 0 250 250"
  class="circular-progress" style="--progress: 50"
>
  <circle class="bg"></circle>
  <circle class="fg"></circle>
</svg>
.circular-progress {
  --size: 250px;
  --half-size: calc(var(--size) / 2);
  --stroke-width: 20px;
  --radius: calc((var(--size) - var(--stroke-width)) / 2);
  --circumference: calc(var(--radius) * pi * 2);
  --dash: calc((var(--progress) * var(--circumference)) / 100);
}

.circular-progress circle {
  cx: var(--half-size);
  cy: var(--half-size);
  r: var(--radius);
  stroke-width: var(--stroke-width);
  fill: none;
  stroke-linecap: round;
}

.circular-progress circle.bg {
  stroke: #ddd;
}

.circular-progress circle.fg {
  transform: rotate(-90deg);
  transform-origin: var(--half-size) var(--half-size);
  stroke-dasharray: var(--dash) calc(var(--circumference) - var(--dash));
  transition: stroke-dasharray 0.3s linear 0s;
  stroke: #5394fd;
}

This may look like a lot, but it's mostly just setting CSS variables and using them to calculate the values we need. One cool thing I want to point out is that the pi constant is available as part of the calc() function! I didn't know this until I started writing this article, and I'm very excited about it.

At this point, if you use some JavaScript to manipulate the value of the --progress variable, you'll see the progress bar fill up. The added transition property will make the progress bar animate smoothly.

Timed progress bar

Have you ever watched an ad inside a mobile game? You know, the ones that give you a reward if you watch the whole thing? They usually have a progress bar that fills up as the ad plays. Or it empties as you watch, much like a countdown timer. Whatever flavor you might have seen, they are variations of the same concept.

How can we create a progress bar that fills up over a predetermined amount of time? We could go about it using JavaScript and Window.requestAnimationFrame(), but that wouldn't be very cool. Instead, we can use the animation property to animate the --progress variable from 0 to 100 over a set amount of time.

@keyframes progress-animation {
  from {
    --progress: 0;
  }
  to {
    --progress: 100;
  }
}

If you try hooking this up to our SVG, you'll notice it doesn't work exactly like you'd think. This is due to the fact that the browser doesn't really know what to do with the --progress variable. It doesn't know it's a number, so it doesn't know how to animate it.

Luckily, CSS has come up with a solution for this. The @property rule allows us to define custom properties and tell the browser what type they are. In this case, we want to tell the browser that --progress is a number.

@property --progress {
  syntax: "<number>";
  inherits: false;
  initial-value: 0;
}
⚠️ Warning

At the time of writing (December, 2023), the @property rule has limited browser support. Please check before using it in production.

Now that the browser knows what to do with the --progress variable, we can hook it up to the animation.

.circular-progress {
  animation: progress-animation 5s linear 0s 1 forwards;
}

This will animate the --progress variable from 0 to 100 over 5 seconds. The forwards keyword tells the browser to keep the final value of the animation. Without it, the progress bar would reset to 0 after the animation is done. You can create the opposite effect by setting the animation-direction property to reverse and using backwards instead of forwards.

Putting it all together

We've covered a lot of ground in this article. We've gone from a simple SVG element to a fully functional progress bar. We've used CSS variables, math functions, and even a new CSS feature. Let's take a look at the final code.

<svg width="250" height="250" viewBox="0 0 250 250" class="circular-progress">
  <circle class="bg"></circle>
  <circle class="fg"></circle>
</svg>
.circular-progress {
  --size: 250px;
  --half-size: calc(var(--size) / 2);
  --stroke-width: 20px;
  --radius: calc((var(--size) - var(--stroke-width)) / 2);
  --circumference: calc(var(--radius) * pi * 2);
  --dash: calc((var(--progress) * var(--circumference)) / 100);
  animation: progress-animation 5s linear 0s 1 forwards;
}

.circular-progress circle {
  cx: var(--half-size);
  cy: var(--half-size);
  r: var(--radius);
  stroke-width: var(--stroke-width);
  fill: none;
  stroke-linecap: round;
}

.circular-progress circle.bg {
  stroke: #ddd;
}

.circular-progress circle.fg {
  transform: rotate(-90deg);
  transform-origin: var(--half-size) var(--half-size);
  stroke-dasharray: var(--dash) calc(var(--circumference) - var(--dash));
  transition: stroke-dasharray 0.3s linear 0s;
  stroke: #5394fd;
}

@property --progress {
  syntax: "<number>";
  inherits: false;
  initial-value: 0;
}

@keyframes progress-animation {
  from {
    --progress: 0;
  }
  to {
    --progress: 100;
  }
}

And here's a CodePen of the code in action:

See the embedded CodePen

Conclusion

Using modern HTML and CSS, we created a circular progress bar. This setup serves as a good starting point for you to experiment with. You can use it as is, or you can extend it to suit your needs, sprinkling in a little bit of JavaScript if you need to. You can even convert it into a Web Component or a React component for your projects.

More like this

Start typing a keyphrase to see matching snippets.