How To Animate Along A Path In CSS

How To Animate Along A Path In CSS

Let’s talk about progress indicators — or loaders. It’s true that there are so many tutorials about them and even more examples floating around CodePen. There was a time just a couple of years ago when loaders seemed to be the go-to example for framework documentation, next to to-do apps.

I recently had the task of creating the loading state for a project, so naturally, I looked to CodePen for inspiration. What I wanted was a circular shape, and there is no shortage of examples. In many cases, the approach is some combination of using the CSS border-radius property to get a circular shape and @keyframes to spin it from 0deg to 360deg.

I needed a little more than that. Specifically, I needed a donut shape that fills in the progress indicator as it goes from 0% to 100%. Thankfully, I found great donut examples I could use for inspiration and several different approaches. For example, I could use the “trick” of an SVG with a stroke that animates with a combination of stroke-dasharray and stroke-dashoffset. Temani Afif has hundreds of examples that use a combination of CSS gradients and masks.

There was still more that I needed. What I really wanted was a donut progress indicator that not only fills in as the progress increases but sets a visual on it that moves with the progress. In other words, I wanted to make it look like an object is traveling around the donut, leaving a trail of progress behind.

See the Pen Circular animation with offset Pt. 1 [forked] by Preethi Sam.

See that? The scooter has a circular track that fills in with a gradient as it moves around the shape. If you’re using Firefox, you likely will have trouble with the demo because it relies on a custom @property that Firefox doesn’t support yet. However, it is supported in the Nightly version, so perhaps we have full support to look forward to soon.

In the end, I wound up combining several of the techniques I found and some additional considerations. I thought I would share the approach because I like demonstrating how various ideas can come together to create something different. This demo uses animated custom properties, a conic gradient, CSS offset, and emoji to produce the effect. The truth is that you may find a different combination or set of techniques that get the job done or fit your requirements better. This is more of a thinking exercise.

Creating The Donut

Circles in CSS are fairly straightforward. We could draw one in SVG and forget CSS entirely. That’s a valid approach, but I’m comfortable working directly in CSS for this sort of thing. We start with a single element in the HTML:

<div class="progress-circle"></div>

From here, we set the circle’s dimensions. That can be done by declaring a width and using an aspect-ratio to maintain a perfect one-to-one shape.

.progress-circle {
  width: 200px; 
  aspect-ratio: 1;
}

Now we can round the shape with the border-radius property:

.progress-circle {
  width: 200px; 
  aspect-ratio: 1;
  border-radius: 50%;
}

That’s our shape! We won’t see anything yet, of course, because we haven’t filled it in with color. Let’s do that now with a conic-gradient. We want one of those because the gradient moves in a circular direction by default, starting at 0% and completing a full circle at 360deg.

.progress-circle {
  width: 200px; 
  aspect-ratio: 1;
  border-radius: 50%;
  background: conic-gradient(red 10%, #eee 0); 
}

So far, so good:

See the Pen Conic Gradient Circle [forked] by Geoff Graham.

What we’re looking at is pretty much a pie chart, right? We’ve established a circular shape and filled it in with a conical gradient that starts with red and hits a hard color stop at #eee, filling in the rest of the pie in a light gray.

The pie is delicious, but we’re aiming for a donut, and donuts have a hole cut out of the center. In the true spirit of CSS, there are different ways to approach this. Again, Temani has demonstrated time and again how CSS masks can do cut-outs. It’s a clean approach, too, because we can repurpose the same conical gradient to cut a circle from the center, only changing the color values to mask out the part we want to hide.

I went a different route, partly for convenience and partly for the sake of demonstrating how CSS is capable of approaching challenges in multiple ways. So, you may even find yourself going with a different route than what we’re demonstrating here. My approach is to use the ::before pseudo-element of the .progress-circle. We lay it on top of the conical gradient with absolute positioning, fill it with a solid color, and size it so it eclipses part of the main shape. It’s basically a smaller solid-colored circle on top of a larger gradient-filled circle.

.progress-circle {
    /* previous styles */
    position: relative;
}
.progress-circle::before {
  content: '';
  position: absolute;
  inset: 20px; 
  border-radius: inherit;
  background: white;
}

Notice what we’re doing to position the smaller circle. Since we’re working with ::before, we need the CSS content property to make it display, even with an empty value. From there, we’re using absolute positioning, setting the smaller circle towards the center with an inset applied in all directions. We’re able to inherit the larger circle’s border-radius before setting a solid background color. We can’t forget to set relative positioning on the larger circle to (a) set a stacking context and (b) keep the smaller circle within the larger circle’s bounds.

See the Pen conic-gradient() [forked] by Preethi Sam.

That’s it for the donut! We accomplished it purely in CSS, relying on a combination of the border-radius property, a conic-gradient, and a well-positioned ::before pseudo-elmement.

Animating The Progress

Have you worked with custom CSS properties? I’m not simply referring to defining --some-variable with a value, but using @property to register a property with a custom syntax. It’s magic how it allows us to interpolate between values that we are normally unable to, such as color and angle values in gradients.

When we register a CSS custom property, we have to mention what its type is, for instance, whether the value is a <length>, <number>, <color> or any of the 11 other types that are supported at the time I’m writing this. This way, the browser understands what sort of value it is working with, and when the time arises, it can update the variable’s value for an animation.

I’m going to register a custom property called --p, which is short for its syntax, <percentage>, with an initial value of 10% that will be the “starting” point for the progress indicator.

@property --p {
  syntax: '';
  inherits: false;
  initial-value: 10%;
}

Now, we can use the --p variable where we need it, such as where the hard color stops between red and #eee in the larger circle’s conical gradient that we’re using as the starting point.

.progress-circle {
    /* previous styles */ 
    background: conic-gradient(red var(--p), #eee 0); 
}

We want to transition from the custom property’s initial value, 10%, to a larger percentage in order to move the gradient’s hard color stop around the shape. So, let’s set up a CSS transition that is designed to update the value of --p.

.progress-circle {
  /* previous styles */ 
  background: conic-gradient(red var(--p), #eee 0); 
  transition: --p 2s linear;
}

We’re going to update the value on hover, transitioning from 10% to 80%:

.progress-circle:hover{
  --p: 80%;
}

One more small adjustment: I like to update the cursor on hover so that it’s clearer what sort of interaction the user is dealing with. In this case, we’re working with something indicating progress, so that’s how we’ll configure it:

.progress-circle {
  /* previous styles */
  cursor: progress;
}

See the Pen conic-gradient() animation [forked] by Preethi Sam.

Our circle is done! We can now hover over the element, and the conical gradient’s hard color stops transitions from 10% to 80% behind the smaller circle that is hiding the rest of the gradient to imply a donut shape. We registered a custom @property with an initial value, applied it to the gradient, and updated the value on hover.

Moving Around The Circle

The final part of this exercise is to work on the progress indicator. We’re using the gradient to indicate progress, but I want the additional visual aid of an object that travels around the larger circle with the gradient as it transitions values.

The idea I had was a little scooter that appears to leave a gradient trail behind it. We already have the circle and the gradient, so all we need is the scooter and a way to make it use the larger circle as a track to drive around.

See the Pen CSS offset animation [forked] by Preethi Sam.

Let’s go ahead and add the scooter to the HTML as an emoji:

<div class="progress-circle">
  <div class="progress-indicator">🛵</div>
</div> 

If we had decided to create the initial donut shape with SVG, then we could have used the same path we used for the larger circle as the track. However, we can still get the same path-making powers in CSS using the offset-path property. It’s so much like writing SVG in CSS that we can actually use the exact same coordinates for an SVG circle in the path():

.chart-indicator {
  /* previous styles */
  offset: path("M 100, 0 a 100 100 0 1 1 -.1 0 z");
}

SVG path coordinates are difficult to read, but this is what we’re doing in this particular path:

  1. M 100, 0: This moves the position of the starting point on an X-Y coordinate system, where 100 is along the X-axis and equal to the larger circle’s radius, or one-half of its width, 200px. The starting point is set to 0 on the Y-axis, placing it at the top of the shape. So, we’re starting at the top-center of the larger circle.
  2. a 100 100: This sets an arc with horizontal and vertical radii of 100, giving us a new circle. Even though we won’t technically see the circle, it is drawn in there, providing the scooter with an invisible track that follows the shape of the larger circle.

One more thing! We have a starting point for the scooter, thanks to the coordinates in the offset-path. The CSS offset-distance property lets us define the end point where we plan to offset the scooter, which is exactly equal to the --p custom property.

.chart-indicator {
  /* previous styles */
  offset-path: path("M 100, 0 a 100 100 0 1 1 -.1 0 z");
  offset-distance: var(--p);
}

We’re already updating our custom --p property on hover to help move the conical gradient’s hard stop position from an initial value of 10% to 80%. We should do the same for the scooter so they move together.

.progress-circle:hover > .progress-indicator { --p: 80%; }

I’m using the child combinator (>) since the indicator is a direct child of the circle. If your design includes additional elements or requires the scooter to be a further descendant, then you might consider a general descendant selector instead.

The Final Result

Here’s everything we covered in a single CSS snippet. I’ve cleaned things up a tiny bit, such as setting up variables for recurring values, like the --size of the circle.

/* Custom property */
@property --p {
  syntax: '<percentage>';
  inherits: false;
  initial-value: 10%;
}

/* Large circle */
.progress-circle {
  --size: 200px; 
  --p: 10%; /* fallback for no @property support */

  background: conic-gradient(red calc(-60% + var(--p)), rgb(224, 187, 77) var(--p), #eee 0);
  border-radius: 50%;
  position: relative;
  margin: auto;
  cursor: progress;
}

/* Small circle pseudo-element */
.progress-circle::before {
  content:'Going ten to eighty percent';
  position: absolute;
  inset: 20px; 
  text-align: center;
  padding: 50px;
  font: italic 9pt 'Enriqueta';
  border-radius: inherit;
  background: white;
}

/* The scooter track */
.progress-indicator {
    --size: min-content; 
    offset: path("M 100,0 a 100 100 0 1 1 -.1 0 z");
    offset-distance: var(--p);
    font: 43pt serif;
    transform: rotateY(180deg) translateX(-6px);
}

/* Update initial value on :hover */
.progress-circle:hover,
.progress-circle:hover > .progress-indicator { 
  --p: 80%;
}

/* Controls the width of larger circle and scooter track */
.progress-circle,
.progress-indicator {
    width: var(--size);
    transition: --p 2s linear;
}

See the Pen Circular animation with offset Pt. 1 [forked] by Preethi Sam.

A scooter and a solid gradient are only one idea. How about different objects with different trails?

See the Pen Circular animation with offset Pt. 2 [forked] by Preethi Sam.

I’ve been referring to this component as both a “progress indicator” and a “loader” throughout the article. There is a difference between displaying progress and loading states, but it’s also possible for a loading state to display the loading progress. That’s why I’m using a generic <div> as a <figure> in the example, but you could just as well use it on more semantic HTML elements, like <progress> or <meter> depending on your specific use case. For accessibility, you might consider incorporating descriptive text that can be announced as assistive-technology-friendly sentences that describe the data.

Let me know if you use this on a project and how you approach it. Share it with me in the comments, and we can compare notes.

Further Reading On SmashingMag

  • “Shines, Perspective, And Rotations: Fancy CSS 3D Effects For Images,” Temani Afif
  • “How To Create Advanced Animations With CSS,” Yosra Emad
  • “A Deep CSS Dive Into Radial And Conic Gradients,” Ahmad Shadeed
  • “A Few Interesting Ways To Use CSS Shadows For More Than Depth,” Preethi Sam