Animated Circular Progress Bar
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.
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.