Stacked Transforms
Stacked Transforms êŽë š
I think the best way for me to show you what I want to show you is to make this blog post a bit like a story. So Iâm gonna do that.
So Iâm at CSS Day in Amsterdam this past month, and there was a lovely side event called CSS CafĂ©. Iâm 90% sure it was during a talk by Johannes Odland and a coworker of his atNRK(whose name I embarrassingly cannot remember) where they showed off something like an illustration of a buoy floating in the water with waves in front of it. Somehow, someway, the CSS property animation-composition was involved, and I was like what the heck is that? I took notes during the presentation, and my notes simply said âanimation-compositionâ, which wasnât exactly helpful.
I nearly forgot about it when I read Josh Comeauâs blog post Partial Keyframes, where he talks about âdynamic, composable CSS keyframesâ, which, as I recall was similar to what Johannes was talking about. There is some interesting stuff in Joshâs post â I liked the stuff about comma-separating multiple animations â but alas, nothing about animation-composition
.
So I figured Iâd stream about it, and so I did that, where I literally read the animation-composition
docs on MDN and played with things. I found their basic/weird demo intriguing and learned from that. Say youâve got a thing and itâs got some transfoms already on it:
.thing {
transform: translateX(50px) rotate(20deg);
}
Then you put a @keyframes
animation on it also:
.thing {
transform: translateX(50px) rotate(20deg);
animation: doAnimation 5s infinite alternate;
}
@keyframes doAnimation {
from {
transform: translateX(0)
}
to {
transform: translateX(100px)
}
}
Pop quiz
what is the translateX()
value going to be at the beginning of that animation?
Itâs not a trick question. If you intuition tells you that itâs going to be translateX(0)
, youâre right. The ânewâ transform
in the @keyframes
is going to âwipe outâ any existing transform
on that element and replace it with what is described in the @keyframes
animation.
Thatâs because the default behavior is animation-composition: replace;
. Itâs a perfectly fine default and likely what youâre used to doing.
But there are other possible values for animation-composition
that behave differently, and weâll look at those in a second. But first, the fact that transform
can take a âspace-separatedâ list of values is already kind of interesting. When you do transform: translateX(50px) rotate(20deg);
, both of those values are going to apply. Thatâs also relatively intuitive once you know itâs possible.
What is less intuitive but very interesting is that you can keep going with more space-separated values, even repeating ones that are already there. And there I definitely learned something! Say we tack on another translateX()
value onto it:
.thing {
transform: translateX(50px) rotate(20deg) translateX(50px);
}
My brain goes: oh, itâs probably basically the same as translateX(100px) rotate(20deg);
. But thatâs not true. The transforms apply one at a time, and in order. So what actually happens is:

Iâm starting to get this in my head, so I streamed again the next day and put it to work.
What popped into my head was a computer language called Logo that I played with as a kid in elementary school. Just look at the main image from the Wikipedia page. And the homepage of the manual is very nostoligic for me.


We can totally make a âturtleâ move like that.
All I did here is put a couple of buttons on the page that append more transform
values to this turtle element. And sure enough, it moves around just like the turtle of my childhood.
But Mr. Turtle there doesnât really have anything to do with animation-composition
, which was the origin of this whole story. But itâs sets up understanding what happens with animation-composition
. Remember this setup?
.thing {
transform: translateX(50px) rotate(20deg);
animation: doAnimation 5s infinite alternate;
}
@keyframes doAnimation {
from {
transform: translateX(0)
}
to {
transform: translateX(100px)
}
}
The big question is: what happens to the transform
that is already on the element when the @keyframes
run?
If we add animation-composition: add;
it adds what is going on in the @keyframes
to what is already there, by appending to the end of the list, as it were.
.thing {
transform: translateX(50px) rotate(20deg);
animation: doAnimation 5s infinite alternate;
animation-composition: add;
}
@keyframes doAnimation {
from {
transform: translateX(0);
/* starts as if:
transform: translateX(50px) rotate(20deg) translateX(0); */
}
to {
transform: translateX(100px);
/* ends as if:
transform: translateX(50px) rotate(20deg) translateX(100px); */
}
}
If we did animation-composition: accumulate;
itâs slightly different behavior. Rather than appending to the list of space-separated values, it increments the values if it finds a match.
.thing {
transform: translateX(50px) rotate(20deg);
animation: doAnimation 5s infinite alternate;
animation-composition: accumulate;
}
@keyframes doAnimation {
from {
transform: translateX(0);
/* starts as if:
transform: translateX(50px) rotate(20deg); */
}
to {
transform: translateX(100px);
/* ends as if:
transform: translateX(150px) rotate(20deg) */
}
}
Itâs not just transform
that behave this way, I just found it a useful way to grok it. (Which is also why I had space-separated filter
on the mind.) For instance, if a @keyframes
was adjusting opacity and we used add
or accumulate
, it would only ever increase an opacity value.
.thing {
opacity: .5;
transform: translateX(50px) rotate(20deg);
animation: doAnimation 2s infinite alternate;
animation-composition: add;
}
@keyframes doAnimation {
from {
opacity: 0;
/* thing would never actually be 0 opacity, it would start at 0.5 and go up */
}
to {
opacity: 1;
}
}
So thatâs that! Understanding how âstackedâ transforms works is very interesting to me and I have a feeling will come in useful someday. And I feel the same way about animation-composition
. You wonât need it until you need it.