Advanced CSS Animation Using cubic-bezier()
Tags: css, webdev, tutorial, codepen
Published: 2021-05-19 12:14:36

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:

1. Multiple animations
2. 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:

{% codepen https://codepen.io/t_afif/pen/eYvmOxR %}

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!

## The cubic-bezier() function

Let's start with [the official definition](https://www.w3.org/TR/css-easing-1/#cubic-bezier-easing-functions):

>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].


![cubic bezier](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/1xr5l3ky4bwjdj17sqwv.png)

The above curve defines how the output (y-axis) will behave based on the time (x-axis). Each axis has a range of {% katex inline %}[0, 1]{% endkatex %} (or {% katex inline %}[0\%, 100\%]{% endkatex %} ). If we have an animation that lasts two-second ({% katex inline %}2s{% endkatex %}), then:

{% katex %}
0 (0\%) = 0s \newline
1 (100\%) = 2s
{% endkatex %}

If we want to animate left from `5px` to `20px`, then:

{% katex %}
0 (0\%) = 5px \newline
1 (100\%) = 20px
{% endkatex %}

X, the time, is always restricted to {% katex inline %}[0, 1]{% endkatex %}; however, Y, the output, can go beyond {% katex inline %}[0, 1]{% endkatex %}.

My goal is to adjust P1 and P2 in order to create the following curves:

![CSS cubic bezier](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/gzvyiub3yhvariwjs165.png)

You may think this is impossible to achieve because, as stated in the definition, {% katex inline %}P_0{% endkatex %} and {% katex inline %}P_3{% endkatex %} are fixed at {% katex inline %}(0,0){% endkatex %} and {% katex inline %}(1,1){% endkatex %} meaning they cannot be on the same axis. That's true, and we will use some math tricks to "approximate" them.

----

## Parabolic curve

Let's start with the following definition: `cubic-bezier(0,1.5,1,1.5)`. That gives us the following curve:

![cubic-bezier(0,1.5,1,1.5)](https://i1.wp.com/css-tricks.com/wp-content/uploads/2021/05/jf8tjkqa6t5zqcybcv08.png?w=648&ssl=1)

Our goal is to move {% katex inline %}(1,1){% endkatex %} and make it at {% katex inline %}(0,1){% endkatex %} which isn’t technically possible. So we will try to fake it.

We previously said that our range is {% katex inline %}[0, 1]{% endkatex %} (or {% katex inline %}[0\%, 100\%]{% endkatex %}) so let's imagine the case when {% katex inline %}0\%{% endkatex %} is very close to {% katex inline %}100\%{% endkatex %}. If, for example, we want to animate top from {% katex inline %}20px{% endkatex %} ({% katex inline %}0\%{% endkatex %}) to {% katex inline %}20.1px{% endkatex %} ({% katex inline %}100\%{% endkatex %}) 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 {% katex inline %}20.1px{% endkatex %} ({% katex inline %}100\%{% endkatex %}). But that's not enough to give us perceptible movement:

{% codepen https://codepen.io/t_afif/pen/abJzoMq %}

Let's update the curve and use `cubic-bezier(0,4,1,4)` instead. Notice how our curve is way taller than before:

| Curve      | Output           |
| ------------- |-------------|
| ![](https://i0.wp.com/css-tricks.com/wp-content/uploads/2021/05/ws9k5wiplk5xu00iaj13.png?w=385&ssl=1)     | {% codepen https://codepen.io/t_afif/pen/eYvmOoR %} |

But yet, still no movement — even if the top value is crossing {% katex inline %}3{% endkatex %} (or {% katex inline %}300\%{% endkatex %}). Let's try `cubic-bezier(0,20,1,20)`:

| Curve      | Output           |
| ------------- |-------------|
| ![](https://i1.wp.com/css-tricks.com/wp-content/uploads/2021/05/r3ytyx91ra931q6iitop.png?w=358&ssl=1)     | {% codepen https://codepen.io/t_afif/pen/VwpYZNV %} |

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 {% katex inline %}(1,1){% endkatex %} "visually" closer to {% katex inline %}(0,1){% endkatex %} when we zoom out to see the full curve and this is the trick.

By using `cubic-bezier(0,V,1,V)` where {% katex inline %}V{% endkatex %} 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:

{% codepen https://codepen.io/t_afif/pen/ZEeYzNy %}

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.

### Digging into the math

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:

{% katex %}
 P = (1−t)^3P_0 + 3(1−t)^2tP_1 + 3(1−t)t^2P_2 + t^3P_3
{% endkatex %}


Each point is defined as follows: 

{% katex %} 
 P_0 = (0,0), 
 P_1 = (0,V) \newline 
 P_2 = (1,V), 
 P_3 = (1,1)
{% endkatex %}

This gives us the two functions for x and y coordinates:

{% katex %}
X(t) = 3(1−t)t^2 + t^3 = 3t^2 - 2t^3
{% endkatex %}
{% katex %}
Y(t) = 3(1−t)^2tV +3(1−t)t^2V + t^3 = t^3 - 3Vt^2 + 3Vt
{% endkatex %}

{% katex inline %}V{% endkatex %} is our big value and {% katex inline %}t{% endkatex %} is within the range {% katex inline %}[0, 1]{% endkatex %}. If we consider our previous example, {% katex inline %}Y(t){% endkatex %} will give us the value of `top` while {% katex inline %}X(t){% endkatex %} is the time progress. The points {% katex inline %}(X(t),Y(t)){% endkatex %} will then define our curve.

Let's find the maximum value of {% katex inline %}Y(t){% endkatex %}. For this, we need to find the value of {% katex inline %}t{% endkatex %} that will give us {% katex inline %}Y'(t) = 0{% endkatex %} (when the derivative is equal to {% katex inline %}0{% endkatex %}):

{% katex %}
Y'(t) = 3t^2 - 6Vt + 3V
{% endkatex %}

{% katex inline %}Y'(t) = 0{% endkatex %} is a quadratic equation. I will skip the boring part and will give you the result, which is 

{% katex %}
t = V - \sqrt{V^2 - V}
{% endkatex %}

When {% katex inline %}V{% endkatex %} is a large value, {% katex inline %}t{% endkatex %} will be equal to {% katex inline %}0.5{% endkatex %}. So, {% katex inline %}Y(0.5) = Max{% endkatex %} and {% katex inline %}X(0.5){% endkatex %} will be equal to {% katex inline %}0.5{% endkatex %}. That means we reach the maximum value at the halfway point in the animation, which conforms to the parabolic curve we want.

Also, {% katex inline %}Y(0.5){% endkatex %} will give us {% katex inline %}\frac{1 + 6V}{8}{% endkatex %} and this will allow us to find the max value based on {% katex inline %}V{% endkatex %}. And since we will always use a big value for {% katex inline %}V{% endkatex %}, we can simplify to {% katex inline %}\frac{6V}{8} = 0.75V{% endkatex %}.

We used {% katex inline %}V = 500{% endkatex %} in the last example, so the max value there would come out to {% katex inline %}375{% endkatex %} (or {% katex inline %}37500\%{% endkatex %}) and we get the following:

* Initial state ({% katex inline %}0{% endkatex %}): `top: 200px`
* Final state ({% katex inline %}1{% endkatex %}): `top: 199.5px`

There's a difference of {% katex inline %}-0.5px{% endkatex %} between {% katex inline %}0{% endkatex %} and {% katex inline %}1{% endkatex %}. Let's call it *the increment*. For {% katex inline %}375{% endkatex %} (or {% katex inline %}37500%{% endkatex %}) we have an equation of {% katex inline %}375*-0.5px = -187.5px{% endkatex %}. Our animated element is reaching `top: 12.5px` ({% katex inline %}200px - 187.5px{% endkatex %}) 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 {% katex inline %}V{% endkatex %} should we use to make our element reach `top: 0px`? The animation will be `200px → 0px → 199.5px`, so we need {% katex inline %}-200px{% endkatex %} to reach {% katex inline %}0px{% endkatex %}. Our increment is always equal to {% katex inline %}-0.5px{% endkatex %}. The max value will be equal to {% katex inline %}\frac{200}{0.5} = 400{% endkatex %}, so {% katex inline %}0.75V = 400{% endkatex %} which means {% katex inline %}V = 533.33{% endkatex %}.

{% codepen https://codepen.io/t_afif/pen/mdWybYY %}

Our element is touching the top!

Here is a figure that sums up that math we just did:

![CSS Parobilic curve](https://i1.wp.com/css-tricks.com/wp-content/uploads/2021/05/sum-up-parabolic.png?resize=1000%2C560&ssl=1)


---

## Sinusoidal curve

We will use almost the exact same trick to create a sinusoidal curve but with a different formula. This time we will use `cubic-bezier(0.5,V,0.5,-V)`

Like we did before, let's see how the curve will evolve when we increase the value:

![CSS sinusoidal curve](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5k1bc2cq87wfthl6c6au.png)

I think you probably get the idea by now. Using a big value for {% katex inline %}V{% endkatex %} gets us close to a sinusoidal curve.

{% codepen https://codepen.io/t_afif/pen/QWpwWwK %}

Here's another one with a continuous animation — a real sinusoidal animation!

{% codepen https://codepen.io/t_afif/pen/qBrEBEM %}

### The math

Let's get in the math for this one! Folllowing the same formula as before, we will get the following functions:


{% katex %}
X(t) = \frac{3}{2}(1−t)^2t + \frac{3}{2}(1−t)t^2 + t^3 = \frac{3}{2}t - \frac{3}{2}t^3 + t^3 
{% endkatex %}
{% katex %}
Y(t) = 3(1−t)^2tV - 3(1−t)t^2V + t^3 = (6V + 1)t^3 - 9Vt^2 + 3Vt
{% endkatex %}

This time we need to find the minimum and maximum values for {% katex inline %}Y(t){% endkatex %}. {% katex inline %}Y'(t) = 0{% endkatex %} will give us two solutions. After solving for this:

{% katex %}
Y'(t) = 3(6V + 1)t^2 - 18Vt + 3V = 0
{% endkatex %}

…we get:

{% katex %}
t' = \frac{3V + \sqrt{3V² - V}}{6V + 1}, 
t'' = \frac{3V - \sqrt{3V² - V}}{6V + 1}
{% endkatex %}

For a big value of {% katex inline %}V{% endkatex %}, we have {% katex inline %}t'=0.211{% endkatex %} and {% katex inline %}t''=0.789{% endkatex %}. That means that {% katex inline %}Y(0.211) = Max{% endkatex %} and {% katex inline %}Y(0.789) = Min{% endkatex %}. That also means that {% katex inline %}X(0.211)= 0.26{% endkatex %} and {% katex inline %}X(0.789) = 0.74{% endkatex %}. In other words, we reach the Max at {% katex inline %}26\%{% endkatex %} of the time and Min at {% katex inline %}74\%{% endkatex %} of the time.

{% katex inline %}Y(0.211){% endkatex %} is equal to {% katex inline %}0.289V{% endkatex %} and {% katex inline %}Y(0.789){% endkatex %} to {% katex inline %}-0.289V{% endkatex %}. We got those values with some rounding considering that {% katex inline %}V{% endkatex %} is very big.

Our sinusoidal curve should also cross the x-axis (or  {% katex inline %}Y(t) = 0{% endkatex %}) at half the time (or {% katex inline %}X(t) = 0.5{% endkatex %}). In order to prove this, we use the second derivate of {% katex inline %}Y(t){% endkatex %} — which should be equal to {% katex inline %}0{% endkatex %} — so {% katex inline %}Y''(t) = 0{% endkatex %}.

{% katex %}
Y''(t) = 6(6V + 1)t - 18V = 0
{% endkatex %}

The solution is {% katex inline %}\frac{3V}{6V + 1}{% endkatex %}, and for a big {% katex inline %}V{% endkatex %} value, the solution is {% katex inline %}0.5{% endkatex %}. That give us  {% katex inline %}Y(0.5) = 0{% endkatex %} and {% katex inline %}X(0.5) = 0.5{% endkatex %} which confirms that our curve crosses the {% katex inline %}(0.5,0){% endkatex %} point.

Now let's consider the previous example and try to find the value of {% katex inline %}V{% endkatex %} that gets us back to `top: 0%`. We have:

* Initial state ({% katex inline %}0{% endkatex %}): `top: 50%`
* Final state ({% katex inline %}1{% endkatex %}): `top: 49.9%`
* Increment: {% katex inline %}-0.1\%{% endkatex %}

We need {% katex inline %}-50\%{% endkatex %} to reach `top: 0%`, so {% katex inline %}0.289V*-0.1\% = -50\%{% endkatex %} which gives us {% katex inline %}V = 1730.10{% endkatex %}.

{% codepen https://codepen.io/t_afif/pen/KKWwKpa %}

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:

![CSS sinusoidal curve](https://i0.wp.com/css-tricks.com/wp-content/uploads/2021/05/sum-up-sinusoidal-1.png?resize=1000%2C583&ssl=1)

And an example to illustrate all curves together:

{% codepen https://codepen.io/t_afif/pen/RwpNwWz %}

Yes, you see four curves! If you look closely, you will notice that I am using two different animations, one going to {% katex inline %}49.9\%{% endkatex %} (an increment of {% katex inline %}-0.01\%{% endkatex %}) and another going to {% katex inline %}50.1\%{% endkatex %} (an increment of {% katex inline %}+0.01\%{% endkatex %}). 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 {% katex inline %}V{% endkatex %} one that should remain a big value) to create more variations from the same curves.

{% codepen https://codepen.io/t_afif/pen/qBrEBbJ %}

And below, an interactive demo:

{% codepen https://codepen.io/t_afif/pen/OJpPJNV %}

----

## Getting back to our example

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:

{% codepen https://codepen.io/t_afif/pen/rNyaNMJ %}

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).

{% codepen https://codepen.io/t_afif/pen/MWpYWbO %}

What kind of drawings can you make with that? Here is a few that I was able to make:

![CSS alien drawing](https://i0.wp.com/css-tricks.com/wp-content/uploads/2021/05/cubic-bezier-spirographs.jpg?resize=1000%2C550&ssl=1)

And here is a spirograph animation:

{% codepen https://codepen.io/t_afif/pen/RwpNwKb %}

And a version without CSS Houdini:

{% codepen https://codepen.io/t_afif/pen/GRWgRWg %}

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 `cubic-bezier()` function.
* The whole animation can be controlled just by adjusting the duration value.

----

## What about transition?

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](https://dev.to/afif/series/12016) 😉

{% codepen https://codepen.io/t_afif/pen/mdWydmd %}

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:

{% codepen https://codepen.io/t_afif/pen/abJzbWR %}


For good measure, here are more fancy hover effects without keyframes (again, Chrome and Edge only). Spoiler for my next collection 😜

{% codepen https://codepen.io/t_afif/pen/poevowW %}

---

## That's it!

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](https://matthewlein.com/tools/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](https://cubic-bezier.com/) is another one. If you want to play with cubic-bezier outside the CSS world, I recommend [desmos](https://www.desmos.com/calculator/ebdtbxgbq0?lang=fr) 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.