When dealing with complex CSS animations, there is a tendency to create expansive
@keyframes with lots of declarations. There are a couple of tricks though that I want to talk about that might help make things easier, while staying in vanilla CSS:
- Multiple animations
- Timing functions
The first one is more widely used and familiar but the second one is less common. There could be good reasons for that — chaining animations with commas is relatively easier than grokking the various timing functions that are available to us and what they do. There's one especially neat timing function that gives us total control to create custom timing functions. That would be
cubic-bezier() and in this post I will show you the power of it and how it can be used to create fancy animation without too much complexity.
Let's start with a basic example showing how we can move a ball around in interesting directions, like an infinity (∞) shape:
As you can see, there is no complex code — only two keyframes and a "strange"
cubic-bezier() function. And yet, a pretty complex-looking final infinity-shape animation is what we get.
Cool, right? Let's dig into this!
Let's start with the official definition:
A cubic Bézier easing function is a type of easing function defined by four real numbers that specify the two control points, P1 and P2, of a cubic Bézier curve whose end points P0 and P3 are fixed at (0, 0) and (1, 1) respectively. The x coordinates of P1 and P2 are restricted to the range [0, 1].
The above curve defines how the output (y-axis) will behave based on the time (x-axis). Each axis has a range of (or ). If we have an animation that lasts two-second ( ), then:
If we want to animate left from
X, the time, is always restricted to ; however, Y, the output, can go beyond .
My goal is to adjust P1 and P2 in order to create the following curves:
You may think this is impossible to achieve because, as stated in the definition, and are fixed at and meaning they cannot be on the same axis. That's true, and we will use some math tricks to "approximate" them.
Let's start with the following definition:
cubic-bezier(0,1.5,1,1.5). That gives us the following curve:
Our goal is to move and make it at which isn’t technically possible. So we will try to fake it.
We previously said that our range is (or ) so let's imagine the case when is very close to . If, for example, we want to animate top from ( ) to ( ) then we can say that both the initial and final states are equal.
Hm, but our element will not move at all, right?
Well, it will move a little because the Y value exceeds ( ). But that's not enough to give us perceptible movement:
Let's update the curve and use
cubic-bezier(0,4,1,4) instead. Notice how our curve is way taller than before:
But yet, still no movement — even if the top value is crossing
). Let's try
Yes! it started to move a little. Did you notice the evolution of the curve each time we increase the value? It's making our point "visually" closer to when we zoom out to see the full curve and this is the trick.
is some very big value and both the initial and final states are very close together (or almost equal), we can simulate the parabolic curve.
An example is worth a thousand words:
I applied the "magic" cubic-bezier function in there to the top animation, plus a linear one applied to left. This gives us the curve we want.
For those of you math-minded folks out there, we can break that explanation down further. A cubic bezier can be defined using the following formula:
Each point is defined as follows:
This gives us the two functions for x and y coordinates:
is our big value and
is within the range
. If we consider our previous example,
will give us the value of
is the time progress. The points
will then define our curve.
Let's find the maximum value of . For this, we need to find the value of that will give us (when the derivative is equal to ):
is a quadratic equation. I will skip the boring part and will give you the result, which is
When is a large value, will be equal to . So, and will be equal to . That means we reach the maximum value at the halfway point in the animation, which conforms to the parabolic curve we want.
Also, will give us and this will allow us to find the max value based on . And since we will always use a big value for , we can simplify to .
We used in the last example, so the max value there would come out to (or ) and we get the following:
- Initial state (
- Final state (
There's a difference of
. Let's call it the increment. For
) we have an equation of
. Our animated element is reaching
top: 12.5px (
) and gives us the following animation:
top: 200px (at 0% of the time ) → top: 12.5px (at 50% of the time) → top: 199.5px (at 100% of the time)
Or, expressed another way:
top: 200px (at 0%) → top: 12.5px (at 50%) → top: 200px (at 100%)
Let's do the opposite logic. What value of
should we use to make our element reach
top: 0px? The animation will be
200px → 0px → 199.5px, so we need
. Our increment is always equal to
. The max value will be equal to
Our element is touching the top!
Here is a figure that sums up that math we just did:
We will use almost the exact same trick to create a sinusoidal curve but with a different formula. This time we will use
Like we did before, let's see how the curve will evolve when we increase the value:
I think you probably get the idea by now. Using a big value for gets us close to a sinusoidal curve.
Here's another one with a continuous animation — a real sinusoidal animation!
Let's get in the math for this one! Folllowing the same formula as before, we will get the following functions:
This time we need to find the minimum and maximum values for . will give us two solutions. After solving for this:
For a big value of , we have and . That means that and . That also means that and . In other words, we reach the Max at of the time and Min at of the time.
is equal to and to . We got those values with some rounding considering that is very big.
Our sinusoidal curve should also cross the x-axis (or ) at half the time (or ). In order to prove this, we use the second derivate of — which should be equal to — so .
The solution is , and for a big value, the solution is . That give us and which confirms that our curve crosses the point.
Now let's consider the previous example and try to find the value of
that gets us back to
top: 0%. We have:
- Initial state (
- Final state (
top: 0%, so
which gives us
As you can see, our element is touching the top and disappearing at the bottom because we have the following animation:
top: 50% → top: 0% → top: 50% → top: 100% → top: 50% → and so on ...
A figure to sum up the calculation:
And an example to illustrate all curves together:
Yes, you see four curves! If you look closely, you will notice that I am using two different animations, one going to (an increment of ) and another going to (an increment of ). By changing the sign of the increment, we control the direction of the curve. We can also control the other parameters of the cubic bezier (not the one that should remain a big value) to create more variations from the same curves.
And below, an interactive demo:
Let's get back to our initial example of a ball moving around in the shape of an infinity symbol. I simply combined two sinusoidal animations to make it work.
If we combine what we did previously with the concept of multiple animations, we can get astonishing results. Here again is the initial example, this time as an interactive demo. Change the values and see the magic:
Let's go further and add a little CSS Houdini to the mix. We can animate a complex transform declaration thanks to
@property (but CSS Houdini is limited to Chrome and Edge support at the moment).
What kind of drawings can you make with that? Here is a few that I was able to make:
And here is a spirograph animation:
And a version without CSS Houdini:
There's a few things to take away from these examples:
- Each keyframe is defined using only one declaration that contain the increment.
- The position of the element and the animation are independent. We can easily place the element anywhere without the need to adjust the animation.
- We made no calculations. There isn't a ton of angles or pixel values. We only need a tiny value within the keyframe and a big value within the
- The whole animation can be controlled just by adjusting the duration value.
The same technique can also be used with the CSS transition property since it follows the same logic when it comes to timing functions. This is great because we're able to avoid keyframes when creating some complex hover effect.
Here's what I made without keyframes. If you were following me you will remember that they are a part of my underline/overlay animation collection 😉
Mario is jumping thanks to the parabolic curve. We needed no keyframes at all to create that shake animation on hover. The sinusoidal curve is perfectly capable of doing all the work.
Here is another version of Mario, this time using CSS Houdini. And, yep, he's still jumping thanks to the parabolic curve:
For good measure, here are more fancy hover effects without keyframes (again, Chrome and Edge only). Spoiler for my next collection 😜
Now you have some magic
cubic-bezier() curves and the math behind them. The benefit, of course, is that custom timing functions like this let us do fancy animations without the complex keyframes we generally reach for.
I understand that not everyone is math-minded and that’s okay. There are tools to help, like Matthew Lein's Ceaser, which lets you drag the curve points around to get what you need. And, if you don't already have it bookmarked, cubic-bezier.com is another one. If you want to play with cubic-bezier outside the CSS world, I recommend desmos where you can see some math formulas.
Regardless of how you get your
cubic-bezier() values, hopefully now you have a sense of their powers and how they can help make for nicer code in the process.