Radial progress indicator using CSS
How to use CSS3 transitions to create an animated radial progress indicator
A colleague of mine recently had the task to implement a design that incorporated radial progress bars. The design was brilliant, the implementation worked (he used geedmos solution), and so he moved on to implementing the rest of the design.
Somehow my thoughts got stuck with that progress indicator. I thought to myself “surely, you can make that indicator spin, maybe even with CSS transitions”. So I stubbornly set out to figure out how to do this.
My research on the net left me wanting, nobody really solved it.
Yes, Jeff Pihach made a “radial progress bar”, but he uses CSS animations rather than CSS transitions. Except pausing and resuming it, you cannot do much with a CSS animation, there is no way to stop it at a predetermined location — like say a third through the animation to set the progress to 33%.
There are jQuery plugins which can do it for you, but they use Javascript — CSS will always have the upper hand performance-wise when it comes to animations, especially on mobile devices.
This is why I set out to implement my own CSS-animated radial progress bar.
Although the title of this post states that the progress indicator is made with CSS, we will actually use less.
The advantages are manifold; primarily we use it because it will be easier to show the calculations used to position elements (later on we will also use it to create some nifty [data-progress=”@{progress}”] selectors).
If you want to skip ahead and see the finished result, you can check out either the result of the last step in this post or have a look at how the HAL cover image was made.
The Basics
To create an animated radial progress indicator, one first has to be able to create an animated circle — a pie chart if you will—that can animate from 0% to 100%.
Let’s begin with a simple div-element rotation and some HTML to start things off with.
<div class="radial-progress">
<div class="circle">
<div class="fill"></div>
</div>
</div>
“radial-progress” will be our root while “circle” will contain anything pertaining to the circle/pie chart we intend to draw.
Colors & Size
“radial-progress” should have a background-color while “fill” should have a color as well.
.radial-progress {
@circle-background: #d6dadc;
@circle-color: #97a71d;
background-color: @circle-background;
.circle .fill {
background-color: @circle-color;
}
}
The entire thing should also have a size:
.radial-progress {
@circle-size: 120px;
width: @circle-size;
height: @circle-size;
.circle .fill {
width: @circle-size;
height: @circle-size;
}
}
Rotation
To rotate the “fill” we preliminarily use some Javascript to set the “transform: rotate()” style.
var transform_styles = ['-webkit-transform',
'-ms-transform',
'transform'];
window.randomize = function() {
var rotation = Math.floor(Math.random() * 360);
for(i in transform_styles) {
$('.circle .fill').css(transform_styles[i],
'rotate(' + rotation + 'deg)');
}
}
The “random” behavior is used for demonstration purposes. Note however how we make the “fill” element turn a full 360º. For the time being we will think of 360º as 100% and e.g. 90º as 25%.
The rotation animation is of course the one we want to animate, so lets add a transition length variable and the css property to “fill”:
.radial-progress {
@transition-length: 1s;
.circle .fill {
transition: -webkit-transform @transition-length;
transition: -ms-transform @transition-length;
transition: transform @transition-length;
}
}
Clipping
Rotating a full circle is no fun, there is simply nothing to see because it is, well… round.
That is why we begin with a half-circle. To do that, we use the CSS clip property. It allows us to make only a specific area of an element visible.
Let’s show only the left half of the circle.
.radial-progress {
.circle .fill {
position: absolute;
/* rect(<top>, <right>, <bottom>, <left>) */
clip: rect(0px, @circle-size/2, @circle-size, 0px);
}
}
We also set the “fill” position to “absolute”, since “clip” will only work on elements positioned that way.
Smoothing the Edges
If you are using Chrome, you will very likely see jagged edges when our div is rotated.
You can avoid this issue by setting the CSS property “-webkit-backface-visibility” to “hidden”.
.radial-progress {
.circle .fill {
-webkit-backface-visibility: hidden;
}
}
You can view the intermediate jsfiddle result of all this before we continue with the next part.
That’s no Circle?!
No, it’s not, great observational skills there ☺
Let’s make it one then! All we need to do is set the border radius to 50%.
.radial-progress {
border-radius: 50%;
.circle .fill {
border-radius: 50%;
}
}
Masks
Remember how we decided that 90º rotation should be 25% etc.? We can get closer to that representation by hiding the “fill” when it is in the left half of the circle.
To do that we once again use the “clip” CSS property. This time we put the “fill” inside another element, which itself is clipped, essentially creating a “mask”.
<div class="radial-progress">
<div class="circle">
<div class="mask">
<div class="fill"></div>
</div>
</div>
</div>
The mask has the same CSS properties as “fill”, with the transition and background-color properties being the exception. Since we only want to show the half-circle on the right side of the circle, we mirror the clipping rectangle of the half-circle:
.radial-progress {
.circle {
.mask {
/* rect(<top>, <right>, <bottom>, <left>) */
clip: rect(0px,@circle-size,@circle-size,@circle-size/2);
.fill {
clip: rect(0px,@circle-size/2,@circle-size,0px);
}
}
}
I recommend fiddling with the clipping areas to get a feel for how they work.
Two Halfs to a Circle
Note how in the previous part the circle moved into the left half again once it passed 180º.
We should limit it to only move 180º and consider that 100%.
window.randomize = function() {
var rotation = Math.floor(Math.random() * 180);
var fill_rotation = rotation / 2;
for(i in transform_styles) {
$('.circle .fill').css(transform_styles[i],
'rotate(' + fill_rotation + 'deg)');
}
}
That change will however leave us with only half a radial progress bar. So let’s add another half-circle, what could possibly go wrong?!
We want the second half-circle to occupy the left half of the circle. As we saw previously, mirroring a clipping rectangle is quite easy.
First, the markup:
<div class="radial-progress">
<div class="circle">
<div class="mask left">
<div class="fill"></div>
</div>
<div class="mask right">
<div class="fill"></div>
</div>
</div>
</div>
The clipping:
.mask.left {
/* rect(<top>, <right>, <bottom>, <left>) */
clip: rect(0px, @circle-size, @circle-size, @circle-size/2);
.fill {
clip: rect(0px, @circle-size/2, @circle-size, 0px);
}
}
.mask.right {
clip: rect(0px, @circle-size/2, @circle-size, 0px);
.fill {
clip: rect(0px, @circle-size, @circle-size, @circle-size/2);
}
}
This is the result:
Alright! We’re getting somewhere, but it is still not really a pie-chart. The second half-circle is always starting at 6 o’clock.
The area filled by the two half-circles however is now representative of a full 100% when we set the rotation of each half-circle to 180º.
Have a look at it on jsFiddle.
Stitching It Together
What we really want is the left half-circle to start where the right one ends.
Recall how the both half-circles are children of masks:
<div class="radial-progress">
<div class="circle">
<div class="mask left">
<div class="fill"></div>
</div>
<div class="mask right">
<div class="fill"></div>
</div>
</div>
</div>
All we need to do is make the visible area of the left mask line up with the edge of the right half-circle.
In a static context of 0º (or 0%) this is easily achieved by rotating the left mask 180º. The right half-circle is behind its mask (i.e. on the left side) and the visible area of the left mask is on the right side of the circle lining up just like we intended.
At 0º, both visible areas of the left and right mask are on the right side of the circle — both half-circles are rotated behind the masks though and not visible (i.e. they’re on the left side of the circle). This makes sense of course, we’re at 0%, so we are not supposed to see anything but the circle background.
At this point, is there a difference between the left and right elements?
After all: their masks and the corresponding visible areas are in the same position and so are the half-circles.
Nope, there isn’t! Let’s hastily cement our enlightenment in code by removing the selector for the left half.
.mask {
/* rect(<top>, <right>, <bottom>, <left>) */
clip: rect(0px, @circle-size, @circle-size, @circle-size/2);
.fill {
clip: rect(0px, @circle-size/2, @circle-size, 0px);
}
}
Now we would see a rather boring animation of two overlapping half-circles filling up until 180º. What were we doing again? Oh yeah:
All we need to do is make the visible area of the left mask line up with the edge of the right half-circle.
How can we achieve that? We rotate the left mask of course!
It will need to be rotated analogous with the half-circles.
$(‘.circle .fill’).css(transform_styles[i], ‘rotate(‘ + rotation + ‘deg)’);
$(‘.circle .mask.left’).css(transform_styles[i], ‘rotate(‘ + rotation + ‘deg)’);
That code will make sure that our right half-circle and left mask always line up.
Here’s the interesting part: Now that the left half-circles parent mask is rotated its absolute rotation is suddenly doubled. At 100% both the left mask and the half-circle would be rotated 180º. Together that makes for 360º rotation. In contrast, the right half-circle still only rotates 180º (its parent mask is not rotated).
In light of those nausea-inducing element-whirls, let us rename the half-circles to “half-spin” and “full-spin”.
Have a look at the jsFiddle for this.
Mind the Gap
Because the browser needs to round to whole pixels you will be able to notice the “seam” between the two half-circles. The best solution I have been able to come up with is adding a third “fill-fix” that rotates together with the two half-circles.
<div class="radial-progress">
<div class="circle">
<div class="mask full">
<div class="fill"></div>
</div>
<div class="mask half">
<div class="fill"></div>
<div class="fill fix"></div>
</div>
</div>
</div>
This “fill-fix” is a child element of the static half-spin mask. It will sufficiently cover the gap if we set its full rotation to 360º. At 100% the “fill-fix” will overlap completely with the full-spin half-circle and not cover the gap, that specific position is however completely vertical for the clipping edges, so no rounding errors occur.
The same CSS as before applies, but we will have to adjust our Javascript:
var rotation = Math.floor(Math.random() * 180);
var fill_rotation = rotation;
var fix_rotation = rotation * 2;
for(i in transform_styles) {
$('.circle .fill, .circle .mask.full').css(transform_styles[i],
'rotate(' + fill_rotation + 'deg)');
$('.circle .fill.fix').css(transform_styles[i],
'rotate(' + fix_rotation + 'deg)');
}
Check out the jsFiddle. You should no longer be able to see any gap.
You can also have a look at a fork of that fiddle which visualizes how exactly the fix-fill rotates and where it is visible.
Prettifying
Now that we have a circle/pie-chart that can animate from 0º all the way to 360º seamlessly, we can begin building on that.
Inset
First, let’s add an inset. This will make our circle look more like an actual radial progress indicator
<div class="radial-progress">
<div class="circle">
<div class="mask full">
<div class="fill"></div>
</div>
<div class="mask half">
<div class="fill"></div>
<div class="fill fix"></div>
</div>
</div>
<div class="inset"></div>
</div>
Have some less variables to wash that DOM down with:
.radial-progress {
@inset-size: 90px;
@inset-color: #fbfbfb;
}
The inset should of course be circular and positioned in the middle of our existing circle. We position it by using a few less calculations:
.radial-progress {
.inset {
width: @inset-size;
height: @inset-size;
position: absolute;
margin-left: (@circle-size - @inset-size)/2;
margin-top: (@circle-size - @inset-size)/2;
background-color: @inset-color;
border-radius: 50%;
}
}
Shadow
We would also like some shadow for three-dimensionality. It is easily added to the inset, so that it falls on our circle fill.
.radial-progress {
@shadow: 6px 6px 10px rgba(0,0,0,0.2);
.inset {
box-shadow: @shadow;
}
}
Now we have a shadow on the bottom right of the inset, which will need to be balanced by one coming from the top left of the circle border. The problem here is however, that we do not have any elements overlaying our two masks, so we will need to add a shadow element.
<div class=”radial-progress”>
<div class=”circle”>
<div class=”mask full”>
<div class=”fill”></div>
</div>
<div class=”mask half”>
<div class=”fill”></div>
<div class=”fill fix”></div>
</div>
<div class=”shadow”></div>
</div>
<div class=”inset”></div>
</div>
It’s shape and size should match that of the circle while the shadow naturally points inwards instead of outwards (inset instead of the default outset).
.radial-progress {
.circle {
.shadow {
width: @circle-size;
height: @circle-size;
position: absolute;
border-radius: 50%;
box-shadow: @shadow inset;
}
}
}
Very pretty indeed! Take a look at the jsFiddle for this part, if you want to play with the inset or shadow settings.
Less is More
The current Javascript solution to setting the rotation is a little unhandy, wouldn’t it be nice if we could just set the progress like so?
$('.radial-progress').attr('data-progress', Math.floor(Math.random() * 100));
This change could open up for a lot of new selectors we might want to add (like changing the color of the circle after 50%). In fact, we will be doing something in that regard later on.
However, writing those selectors will be a pain, we would have to define every selector from [data-progress=”%0”] to [data-progress=”%100”] and set the rotations accordingly.
Luckily less can loop using recursive calls.
.radial-progress {
@i: 0;
.loop (@i) when (@i <= 100) {
&[data-progress="@{i}"] {
.circle { }
}
.loop(@i + 1);
}
.loop(@i);
}
All we need to do is fill in the blanks. The first order of business is figuring out how many degrees correspond to 1%:
.radial-progress {
@increment: 180deg / 100;
}
Remember that 180º actually causes a 360º spin for the full-spin element, because the full-spin mask rotation adds to the rotation of its child element.
The Javascript we used corresponds to the same transform css property with a little math added.
&[data-progress="@{i}"] {
.circle {
.mask.full, .fill {
-webkit-transform: rotate(@increment * @i);
-ms-transform: rotate(@increment * @i);
transform: rotate(@increment * @i);
}
.fill.fix {
-webkit-transform: rotate(@increment * @i * 2);
-ms-transform: rotate(@increment * @i * 2);
transform: rotate(@increment * @i * 2);
}
}
}
The result is the same as before, but you now have an easy way to adjust the percentage of your radial progress indicator.
But How Much is That?
Indeed. Up until now we have only shown the progress graphically, but we will need a percentage to properly indicate how far we have gotten.
We use the inset as a container for the percentage element.
<div class="radial-progress" data-progress="0">
<div class="circle">
<div class="mask full">
<div class="fill"></div>
</div>
<div class="mask half">
<div class="fill"></div>
<div class="fill fix"></div>
</div>
</div>
<div class="inset">
<div class="percentage"></div>
</div>
</div>
Since we already have selectors that match the given percentages, we might as well keep using them and insert the percentage using CSS.
.radial-progress {
&[data-progress="@{i}"] {
.inset .percentage:before {
content: "@{i}%"
}
}
}
To properly position the percentage smack in the center of the inset, we introduce some variables that we can use for calculations later on.
.radial-progress {
@percentage-color: #97a71d;
@percentage-font-size: 22px;
@percentage-text-width: 44px;
}
And a nice font.
@import url(http://fonts.googleapis.com/css?family=Lato:100,300,400,700,900,100italic,300italic,400italic,700italic,900italic);
The CSS properties are rather straightforward, note how we position the element vertically by using half the difference between the inset height and the text line-height.
.radial-progress {
.inset .percentage {
width: @percentage-text-width;
position: absolute;
top: (@inset-size - @percentage-font-size) / 2;
left: (@inset-size - @percentage-text-width) / 2;
line-height: 1;
text-align: center;
font-family: "Lato", "Helvetica Neue", Helvetica, Arial, sans-serif;
color: @percentage-color;
font-weight: 800;
font-size: @percentage-font-size;
}
}
Here is the resulting jsFiddle.
Animate All the Things!
The result is splendid (if I may say so myself). But we can go even further: Why should the percentage text not be animated as well?! It shouldn’t be a slider that simply moves the next number into place, but rather a quick switch from one number to the next.
This is where CSS gets complicated. Its transformations were built for smooth animations between two states and not abrupt switches, so how can we achieve with CSS animations exactly what they are trying to avoid?
The answer is line breaks.
Line breaks move entire elements onto the next line once there is no space for them on the current one. We can abuse that behavior to animate our percentages. All we need is an element with all our numbers on one line, a container that only shows the first number on the next line, and an animation that widens or narrows the element depending on the percentage we set.
Why a container instead of a mask like in our previous half-circle endeavors you ask? The side-scroll would become enormous, because clipping an element only reduces its visible area, not the actual size of it.
With those three above mentioned elements in place the percentage that corresponds to the circle size will always be the first number on the second line during animation.
For starters we adjust the markup to contain all numbers from 0%-100% inside a new “numbers” element.
<div class=”radial-progress” data-progress=”0">
<div class=”circle”>
<div class=”mask full”>
<div class=”fill”></div>
</div>
<div class=”mask half”>
<div class=”fill”></div>
<div class=”fill fix”></div>
</div>
</div>
<div class=”inset”>
<div class=”percentage”>
<div class="numbers"><span>-</span><span>0%</span><span>1%</span><span>2%</span><span>3%</span>…<span>99%</span><span>100%</span></div>
</div>
</div>
</div>
The first empty <span> is necessary so we can get 0% to jump to the second line as well.
We will need to move the numbers element one line-height up to have the second line be in the center of the inset. Also, we only want to show that second line (and only the first number), so we set the height of the outer percentage element to be that of the percentage font-size. Lastly we want to animate width changes in the numbers element.
.radial-progress {
.inset .percentage {
height: @percentage-font-size;
transition: width @transition-length;
.numbers {
margin-top: -@percentage-font-size;
transition: width @transition-length;
}
}
}
The span elements inside “numbers” should be centered and have a constant width to ensure that each number jumps to the next line at the right time. Since we are displaying it as an inline-block element we need to fiddle with the vertical alignment.
.radial-progress {
.inset .percentage {
span {
width: @percentage-text-width;
display: inline-block;
vertical-align: top;
text-align: center;
}
}
}
A simple change in our data-progress selector makes sure that the width on the numbers element changes proportionally to the percentage set on the root.
.radial-progress {
&[data-progress=”@{i}”] {
.inset .percentage .numbers {
width: @i*@percentage-text-width + @percentage-text-width;
}
}
}
And that’s it! The numbers are now whooshing by when progress is made.
Oops. We might need one last thing.
.radial-progress {
.inset .percentage {
overflow: hidden;
}
}
Much better!
Check out the jsFiddle to see how everything plays together.
Going further
There are various paths we could take to extend the indicator.
Like implementing more than one half-circle to show colored segments or changing the color of the entire circle depending on the percentage.
The variables in less are not only useful to illustrate how things fit together, you can easily adjust them to make the inset larger and only have a slim progress indicator.
The easing function is also adjustable, have a look at how you can specify your very own timing function to make the animation behave exactly the way you like.
Instead of forking the various fiddles I linked to throughout this post, you can also look at everything on github maybe even fork the repo and improve upon my methods.
About the Author
Anders Ingemann works for Secoya A/S, a company dedicated to providing advanced knowledge management solutions. In his spare time he rock climbs in the danish mountains and saves orphanages from supervillains. He is the author of the open source projects homeshick and bootstrap-vz.