Picture of the author

Animated Circular Progress Bar

Published on

tl;dr The final result is here https://codepen.io/nag5000/pen/YzMyoQN

We are creating something like this:

Amazingly, this can be done using only Vanilla HTML & CSS, without JS. JavaScript is only needed to change the progress value for demo purposes.

All modern browsers now support the CSS at-rule @property:

The @property CSS at-rule is part of the CSS Houdini umbrella of APIs. It allows developers to explicitly define their CSS custom properties, allowing for property type checking and constraining, setting default values, and defining whether a custom property can inherit values or not.

css at-rule: property on caniuse

Chrome supported this quite a while ago, starting with version 85; Safari introduced it in version 16.4; Firefox took longer, but finally added it in version 128.

@property in CSS (or CSS.registerProperty() in JS) allows us to define the property type, making it animatable. For example, if you create a property of type percentage, you can animate a gradient using this property as a color stop.

Creating the Circular Progress Bar

A circular progress bar can be implemented using a gradient as well, but instead of a linear-gradient, we use a conic-gradient:

Next, we cut out the inner circle of the progress bar. This can be done quite simply using an additional element on top with a white background (e.g., ::after) – this can be a good enough solution, but it also has the drawback that the background will be hardcoded and, in general, will not match the page background. Therefore, we will use a mask:

Now, let's animate the progress.

We will move the progress value to a CSS variable --circular-progress-value and set its type to number, as well as set a transition for it:

@property --circular-progress-value {
  syntax: '<number>';
  inherits: true;
  initial-value: 0;
}

span {
  background: conic-gradient(orange calc(var(--circular-progress-value) * 1%), lightgrey 0);
  transition: --circular-progress-value 1s;
}

For demo, we will change the variable's value every 3 seconds using JS:

setInterval(() => {
  const progress = getRandomInt(0, 100)
  el.style.setProperty('--circular-progress-value', progress)
}, 3000)

function getRandomInt(min, max) {
  min = Math.ceil(min);
  max = Math.floor(max);
  return Math.floor(Math.random() * (max - min + 1)) + min;
}

Now, if the browser supports @property, the progress will change with animation:

Note on A11y

The current implementation is an empty span tag, which is not accessible. The simplest and most reliable solution would be to add a native progress tag inside the span and duplicate its progress value:

<progress min="0" max="100" value="50" class="sr-only"></progress>

Adding Text to Display the Progress Value

Adding text inside the progress bar is not difficult in general, but to make it animated, we will use a trick: CSS counters, which are intended for numbered lists.

Using counter-reset and content: counter() we can add the progress value without duplicating it in HTML, and also animate it, again thanks to @property. counter-reset allows setting an arbitrary starting value instead of the default 1.

However, there is a nuance. We set the type of --circular-progress-value to number (floating point numbers), but counter-reset requires integers.

If we change the property type to integer, the animation of the progress bar will not be smooth. This issue can be resolved using an additional property --circular-progress-value-int, for which we will set the type integer:

@property --circular-progress-value-int {
  syntax: '<integer>';
  inherits: true;
  initial-value: 0;
}

Now, if we assign the variable --circular-progress-value-int to --circular-progress-value in CSS, the value will be rounded to the nearest integer. However, instead of rounding, we prefer Math.floor() in our case. This can be done in the following ways (either one):

--circular-progress-value-int: round(down, var(--circular-progress-value), 1);
--circular-progress-value-int: max(var(--circular-progress-value) - 0.5, 0);

We will display the value in the after pseudo-element:

span {
  // ...
  --circular-progress-value-int: round(down, var(--circular-progress-value), 1);
  counter-reset: progress var(--circular-progress-value-int);
}

span::after {
  content: counter(progress) '%';
}

If we check the result now, we will see that the pseudo-element is not visible. This is because we used mask to cut out the inner circle. To solve this problem, we can move the background and mask to the before pseudo-element.

Next, we can refactor and clean up the code a bit, resulting in the following:

If the browser does not support @property, the progress will change instantly, without animation. Since all modern browsers now support @property, this solution can be used as a Progressive Enhancement.