Repeatable, Staggered Animation Three Ways: Sass, GSAP and Web Animations API

Avatar of Opher Vishnia
Opher Vishnia on (Updated on )

Staggered animation, also known as “follow through” or “overlapping action” is one of the twelve Disney principles of animation as defined by Ollie Johnston and Frank Thomas in their 1981 book “The Illusion of Life”. At its core, the concept deals with animating objects in delayed succession to produce fluid motion.

The technique doesn’t only apply to cute character animations though. The Motion design aspect of a digital interface has significant implications on UX, user perception and “feel”. Google even makes a point to mention staggered animation in its Motion Choreography page, as part of the Material Design guide:

While the topic of motion design is truly vast, I often find myself applying bits and pieces even in smallest of projects. During the design process of the Interactive Coke ad on Eko I was tasked with creating some animation to be shown as the interactive video is loading, and so this mockup was born:

At a first glance, this animation seems trivial to implement in CSS, but turns out that is not that case! While it might be simpler with GSAP and the shiny new Web Animations API, doing so with CSS requires a few tricks which I’m going to explain in this post. Why use CSS at all then? In this case — as the animation was meant to run while the user waits for assets to load, it didn’t make much sense to load an animation library just to display a loading spinner.

First, a bit about the anatomy of the animation.

There are four circles, absolutely positioned within a container with overflow: hidden to frame and crop the edges of the two outermost circles. Why four and not three? Because the first one is offscreen, waiting to enter stage left and the last one exists the frame stage right. The other two are always in the frame. This way, the end state of the animation iteration looks exactly like its beginning state. Circle 1 takes circle 2’s place, circle 2 takes circle 3’s place and so on.

Here’s the basic HTML:

<div id="container">

And the accompanying CSS:

#container {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  width: 160px;
  height: 40px;
  display: block;
  overflow: hidden;
span {
  width: 40px;
  height: 40px;
  border-radius: 50%;
  background: #4df5c4;
  display: inline-block;
  position: absolute; 
  transform: translateX(0px);

Let’s try this out with a simple animation for each circle that translates X from 0 to 60 pixels:

See the Pen dot loader – no stagger by Opher Vishnia (@OpherV) on CodePen.

Looks kind of weird and robotic, right? That’s because we’re missing one major component: Staggered animation. That is, each circle’s animation needs to start a bit after its predecessor. “No problem!”, you might think to yourself, “let’s use the animation-delay” property. “We’ll give the 4th circle a value of 0s, the 3rd of 0.15s and so on”. Alright, let’s try that:

See the Pen dot loader – broken by Opher Vishnia (@OpherV) on CodePen.

Hmm… What just happened? The property animation-delay affects only the initial delay before the animations starts. It doesn’t add additional delays between every iteration so the animation goes out of sync like in the following diagram:

Math to the rescue

To overcome this, I baked the delay into the animation. CSS keyframe animations are specified in percents, and with some calculation, you can use those to define how much delay should the animation include. For example, if you set an animation-duration of 1s, and specify your start keyframe at 0%, the same values at 20%, your end at 80% and the same end values at 100%, your animation will wait 0.2 seconds, run for 0.6 seconds, then wait for another 0.2 seconds.

In my case, I wanted each circle to wait with a stagger time of 0.15 seconds before performing the actual animation taking 0.5 seconds, with the entire process taking 1 second. This means that the 4th circle animation waits 0 seconds, then animates for 0.5 seconds and waits for another 0.5 seconds. The second circle waits 0.15 seconds, then animates 0.5 seconds and waits for 0.35 seconds and so forth.

To achieve this, you need four keyframes (or three keyframe pairs): 1 and 2 account for the stagger wait, 2 and 3 for the actual animation time while 3 and 4 account for the final wait. The “trick” is to understand how to convert the required timings into keyframe percentages, but that’s a relatively simple calculation. For example, the 2nd circle needs to wait 0.15 * 2 = 0.3 seconds, then animate for 0.5 seconds. I know the total time for the animation is one second, so the keyframe percentages are calculated like so:

0s = 0%
0.3s = 0.3 / 1s * 100 =  30%
0.8s = (0.3 + 0.5) / 1s * 100 = 80%
1s = 100%

The end result looks something like this:

With the entire animation, including stagger time and wait baked into the CSS keyframes taking exactly one second, the animation doesn’t go out of sync.

Luckily, Sass allows us automate this process with a simple for loop and some inline math, which ultimately compiles into a series of keyframe animations. This way you can manipulate the timing variables to experiment and test whatever works best for your animation:

@mixin createCircleAnimation($i, $animTime, $totalTime, $delay) {      
  @include keyframes(circle#{$i}) {
    0% {              
      @include transform(translateX(0));            
    #{($i * $delay)/$totalTime * 100}% {     
      @include transform(translateX(0));            
    #{($i * $delay + $animTime)/$totalTime * 100}% {     
      @include transform(translateX(60px));            
    100% {
      @include transform(translateX(60px));             

$animTime: 0.5s;
$totalTime: 1s;
$staggerTime: 0.15s;

@for $i from 0 through 3 {
  @include createCircleAnimation($i, $animTime, $totalTime, $staggerTime); 
  span:nth-child(#{($i + 1)}) {
    animation: circle#{(3 - $i)} $totalTime infinite;
    left: #{$i * 60 - 60 }px;

And voila — here’s the final result


p data-height=”450 data-theme-id=” 1″=”” data-slug-hash=”bEydYo” data-default-tab=”result” data-user=”OpherV” data-embed-version=”2″ data-pen-title=”dot loading animation – SASS stagger” class=”codepen”>See the Pen dot loading animation – SASS stagger by Opher Vishnia (@OpherV) on CodePen.

There are two main caveats with this method:

First, you need to make sure the defined stagger time/animation time isn’t too long that it overlaps the total animation time, otherwise the math (and the animation) will break.

Second, this method does generate some hefty amount of CSS code, especially if you’re using Sass to emit all the prefixes for browser compatibility. In my example, I had only four items to animate, but if yours has more items, the amount of code generated might not be worth the effort, and you probably want to stick with JS based animation libraries such as GSAP. Still, doing this entirely in CSS is pretty cool.

Making life easier

To contrast the verbosity of the Sass solution, I’d like to show you how the same can be easily achieved with the use of GSAP’s Timeline, and staggerTo function:

See the Pen dot loading animation – GSAP by Opher Vishnia (@OpherV) on CodePen.

There are two interesting bits here. First, the last parameter of staggerTo, which defines the wait time between animating elements is set to a negative value (-0.15). This allows the elements to stagger in reverse order (circle 4–3–2–1 instead of 1–2–3–4). Cool, huh?

Second, see the bit with tl.set({}, {}, "1");? What’s this weird syntax all about? That’s a neat hack to implement the wait time at the end each circle’s animation. Essentially by setting an empty object to an empty object at time 1, the Timeline animation will now repeat after the 1-second mark, rather than after the circle animation had ended.

Looking forwards to the future

The Web Animations API is the new and exciting kid on the block, but out of scope for this article. I couldn’t resist providing you with a sample implementation though, which uses the same math as the CSS implementation:

See the Pen dot loading animation – WAAPI by Opher Vishnia (@OpherV) on CodePen.

Was this helpful? Have you created some smooth animations using this technique? Let me know!