DEV Community

Cover image for Mesmerizing animation using only CSS translations 🪄
Mustapha Aouas
Mustapha Aouas

Posted on • Updated on

Mesmerizing animation using only CSS translations 🪄

Two weeks ago I shared this animation using only rotations. This time I'm back at it, but only using translations.

Newton said time is absolute, Einstein said time is relative. I don't know who's right and who's wrong, but what I can say is that timing is everything (at least for this animation).
So what do you see?

ℹ️ If you can't see the Codepen above, click here to see the animation.

Disclamer
On i̶n̶t̶e̶r̶n̶e̶t̶ ̶e̶x̶p̶l̶o̶r̶e̶r̶ iOS safari the implementation below doesn't work correctly because for some reason we can't animate left from 0% to 100%. So I did a special implementation for safari. I talk more about it at the end of the article.

Follow me on Twitter at twitter.com/theAngularGuy as I tweet (almost) every day about the things I learned in my 6+ years journey in web development ✅


Step 1

Ok so let's start right away. The first thing is to place all the 12 dots on a horizontal container like this (I outlined each line wrapper):

STEP 1

To achieve this, nothing extraordinary, we have a container div that contains the horizontal wrappers around the dots.

.wrapper {
  width: 200px;
  margin: auto;
  display: flex;
  flex-direction: column;
  align-items: center;

  .line {
    width: 100%;
    height: 20px;
    position: relative;

     .dot {
      width: 20px;
      height: 20px;
      border-radius: 50%;
      position: absolute;
      background: #fff;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 2

Pendulum

Next, we need to simulate this pendulum effect using only translations. For this we need each ball moving left and right and we also need each ball to be at a slightly lower vertical position when it hits its equilibrium position (when it's in the middle of the line wrapper).

The balls' position starts at the left, so we want them to move to the right then return to the left. So, at the start of the animation, we set left to 0, at fifty percent of the animation we set it at 100% (minus the width of the ball so it doesn't overflow), and finally, at one hundred percent of the animation we set left back to 0;

With this, each ball will hit its equilibrium position at 25% (of the animation) and at 75% (on the return). So at these timings, we need to move the ball to the bottom.
So the animation code looks like this:

@keyframes move {
  0% {
    top: 0; left: 0;
  } 25% {
    top: 7px;
  } 50% {
    top: 0; left: calc(100% - 20px);
  } 75% {
    top: 7px;
  } 100% {
    top: 0; left: 0;
  }
}
Enter fullscreen mode Exit fullscreen mode

And this is the result we have so far:

almost there

Step 3

We are almost there. We only need two things:

  1. Decrease the width of each line wrapper from bottom to top (so each line width increase from top to bottom)
  2. Decrease the animation speed (or increase the animation duration) from top to bottom (because the more a ball is near the bottom the more distance it needs to travel)

You can do this in many different ways, I choose to do it using sass for through loop (there is also the for to loop but the final number is excluded).

@for $i from 1 through 12 {
  .line:nth-child(#{$i}) {
    width: calc(100% - 12% + #{$i}%);

    .dot {
      animation-duration: calc(1s + calc(#{$i} * 5ms));
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

And that's it actually. With only these few lines of CSS (or scss 😉) we completed this crazy animation. Here's the final code:

.wrapper {
  width: 200px;
  margin: auto;
  display: flex;
  flex-direction: column;
  align-items: center;

  .line {
    height: 20px;
    position: relative;
    margin-bottom: 7px;

    .dot {
      background: #fff;
      width: 20px;
      height: 20px;
      border-radius: 50%;
      position: absolute;
      animation: move 1s ease-in-out infinite;
    }
  }

  @for $i from 1 through 12 {
    .line:nth-child(#{$i}) {
      width: calc(100% - 12% + #{$i}%);

      .dot {
        animation-duration: calc(1s + calc(#{$i} * 5ms));
      }
    }
  }
}

@keyframes move {
  0% {
    top: 0; left: 0;
  } 25% {
    top: 7px;
  } 50% {
    top: 0; left: calc(100% - 20px);
  } 75% {
    top: 7px;
  } 100% {
    top: 0; left: 0;
  }
}
Enter fullscreen mode Exit fullscreen mode

Safari implementation

So like I said before, on Safari (iOS) we can't animate left from 0% to 100% (at the moment I write these lines). So we have to be a little cleaver in the way we place the dots to translate them using the transform property.

This fix was suggested by u/mountainunicycler.

There are two problems if we want to use the transform CSS property:

  1. First, each line wrapper width is different so we can't use pixels (px unit). We have to use percent (%). But when we transform an HTML element the percent unit is relative to the element itself and not the parent container like with left. So if a dot is 20px large and we translate it by 100% it will move by 20px. That's not what we want.
  2. Second, we have two animations: from left to right and from top to bottom, and it's tricky because the two animations use the same property: transform (So they will overload each other).

To fix these problems, we will set the dots widths to 100% (so when we translate them by a percentage, they will move (pretty much) relatively to their parents since they have the same width). And to draw the dots we will use the ::after pseudo-element and make it 20px large and 20px tall (original size of the dots).

Then to fix the second problem we will animate two elements instead of one (so the horizontal translation won't mess with the vertical translation since these translations will be applied to two different HTML elements). We will animate the .dot horizontally and we will animate the .dot::after pseudo-element vertically.
So, this is the code we end up with:

// Little hack to detect safari browsers
@media not all and (min-resolution: 0.001dpcm) {
  .wrapper {
    .line {
      position: relative;
      width: 100%;

      .dot {
        background: transparent; // resetting the background
        box-shadow: none; // resetting the box-shadow
        width: 100%;
        animation: moveHoriz 1s ease-in-out infinite;

        &::after {
          content:'';
          position: absolute;
          background: $color;
          box-shadow: 1px 1px 12px 2px $color;
          width: 20px;
          height: 20px;
          border-radius: 50%;
          animation: moveVert ease-in-out infinite;
        }
      }
    }

    @for $i from 1 through 12 {
      .line:nth-child(#{$i}) {
        width: calc(100% - 12% + #{$i}%);

        .dot, .dot::after {
          animation-duration: calc(1s + calc(#{$i} * 5ms));
        }
      }
    }
  }

  @keyframes moveVert {
    0% {
      transform: translateY(0);
    } 25% {
      transform: translateY(7px);
    } 50% {
      transform: translateY(0px);
    } 75% {
      transform: translateY(7px);
    } 100% {
      transform: translateY(0);
    }
  }

  @keyframes moveHoriz {
    0% {
      transform: translateX(0);
    } 50% {
      transform: translateX(calc(100% - 20px));
    } 100% {
      transform: translateX(0);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

ℹ️ I purposefully left the two animations because I think the first one is easier to implement and easier to grasp. But the second one (for Safari) works on all evergreen browsers and at some extent is more performant (animating the transform property is more performant than the left and top properties).



That's it for this post. I hope you liked it. If you did, please share it with your friends & colleagues and follow me on Twitter at @theAngularGuy where I tweet about web development and computer science.

Cheers!


What to read next?

Discussion (2)

Collapse
princechigozie11 profile image
Princechigozie11

Cool, Loving your post keep it coming

Collapse
andrewbaisden profile image
Andrew Baisden

Pretty cool!