I’ve been playing with CSS transforms for over five years and one thing that has always bugged me was that I couldn’t animate the components of a transform
chain individually. This article is going to explain the problem, the old workaround, the new magic Houdini solution and, finally, will offer you a feast of eye candy through better looking examples than those used to illustrate concepts.
The Problem
In order to better understand the issue at hand, let’s consider the example of a box we move horizontally across the screen. This means one div
as far as the HTML goes:
The CSS is also pretty straightforward. We give this box dimensions, a background
and position it in the middle horizontally with a margin
.
$d: 4em;
.box { margin: .25*$d auto; width: $d; height: $d; background: #f90;
}
See the Pen by thebabydino (@thebabydino) on CodePen.
Next, with the help of a translation along the x axis, we move it by half a viewport (50vw
) to the left (in the negative direction of the x axis, the positive one being towards the right):
transform: translate(-50vw);
See the Pen by thebabydino (@thebabydino) on CodePen.
Now the left half of the box is outside the screen. Decreasing the absolute amount of translation by half its edge length puts it fully within the viewport while decreasing it by anything more, let’s say a full edge length (which is $d
or 100%
—remember that %
values in translate()
functions are relative to the dimensions of the element being translated), makes it not even touch the left edge of the viewport anymore.
transform: translate(calc(-1*(50vw - 100%)));
See the Pen by thebabydino (@thebabydino) on CodePen.
This is going to be our initial animation position.
We then create a set of @keyframes
to move the box to the symmetrical position with respect to the initial one with no translation and reference them when setting the animation
:
$t: 1.5s;
.box { /* same styles as before */ animation: move $t ease-in-out infinite alternate;
}
@keyframes move { to { transform: translate(calc(50vw - 100%)); }
}
This all works as expected, giving us a box that moves from left to right and back:
See the Pen by thebabydino (@thebabydino) on CodePen.
But this is a pretty boring animation, so let’s make it more interesting. Let’s say we want the box to be scaled down to a factor of .1
when it’s in the middle and have its normal size at the two ends. We could add one more keyframe:
50% { transform: scale(.1); }
The box now also scales (demo), but, since we’ve added an extra keyframe, the timing function is not applied for the whole animation anymore—just for the portions in between keyframes. This makes our translation slow in the middle (at 50%
) as we now also have a keyframe there. So we need to tweak the timing function, both in the animation
value and in the @keyframes
. In our case, since we want to have an ease-in-out
overall, we can split it into one ease-in
and one ease-out
.
.box { animation: move $t ease-in infinite alternate;
}
@keyframes move { 50% { transform: scale(.1); animation-timing-function: ease-out; } to { transform: translate(calc(50vw - 100%)); }
}
See the Pen by thebabydino (@thebabydino) on CodePen.
Now all works fine, but what if we wanted different timing functions for the translation and scaling? The timing functions we’ve set mean the animation
is slower at the beginning, faster in the middle and then slower again at the end. What if we wanted this to apply just to the translation, but not to the scale? What if we wanted the scaling to happen fast at the beginning, when it goes from 1
towards .1
, slow in the middle when it’s around .1
and then fast again at the end when it goes back to 1
?
Well, it’s just not possible to set different timing functions for different transform functions in the same chain. We cannot make the translation slow and the scaling fast at the beginning or the other way around in the middle. At least, not while what we animate is the transform
property and they’re part of the same transform
chain.
The Old Workaround
There are of course ways of going around this issue. Traditionally, the solution has been to split the transform
(and consequently, the animation
) over multiple elements. This gives us the following structure:
</div>
We move the width
property on the wrapper. Since div
elements are block elements by default, this will also determine the width
of its .box
child without us having to set it explicitly. We keep the height
on the .box
however, as the height
of a child (the .box
in this case) also determines the height
of its parent (the wrapper in this case).
We also move up the margin
, transform
and animation
properties. In addition to this, we switch back to an ease-in-out
timing function for this animation
. We also modify the move
set of @keyframes
to what it was initially, so that we get rid of the scale()
.
.wrap { margin: .25*$d calc(50% - #{.5*$d}); width: $d; transform: translate(calc(-1*(50vw - 100%))); animation: move $t ease-in-out infinite alternate;
}
@keyframes move { to { transform: translate(calc(50vw - 100%)); }
}
We create another set of @keyframes
which we use for the actual .box
element. This is an alternating animation
of half the duration of the one producing the oscillatory motion.
.box { height: $d; background: #f90; animation: size .5*$t ease-out infinite alternate;
}
@keyframes size { to { transform: scale(.1); } }
We now have the result we wanted:
See the Pen by thebabydino (@thebabydino) on CodePen.
This is a solid workaround that doesn’t add too much extra code, not to mention the fact that, in this particular case, we don’t really need two elements, we could do with just one and one of its pseudo-elements. But if our transform chain gets longer, we have no choice but to add extra elements. And, in 2018, we can do better than that!
The Houdini Solution
Some of you may already know that CSS variables are not animatable (and I guess anyone who didn’t just found out). If we try to use them in an animation
, they just flip from one value to the other when half the time in between has elapsed.
Consider the initial example of the oscillating box (no scaling involved). Let’s say we try to animate it using a custom property --x
:
.box { /* same styles as before */ transform: translate(var(--x, calc(-1*(50vw - #{$d})))); animation: move $t ease-in-out infinite alternate
}
@keyframes move { to { --x: calc(50vw - #{$d}) } }
Sadly, this just results in a flip at 50%
, the official reason being that browsers cannot know the type of the custom property (which doesn’t make sense to me, but I guess that doesn’t really matter).
See the Pen by thebabydino (@thebabydino) on CodePen.
But we can forget about all of this because now Houdini has entered the picture and we can register such custom properties so that we explicitly give them a type (the syntax
).
For more info on this, check out the talk and slides by Serg Hospodarets.
CSS.registerProperty({ name: '--x', syntax: '<length>', initialValue: 0
});
We’ve set the initialValue
to 0
, because we have to set it to something and that something has to be a computationally independent value—that is, it cannot depend on anything we can set or change in the CSS and, given the initial and final translation values depend on the box dimensions, which we set in the CSS, calc(-1*(50vw - 100%))
is not valid here. It doesn’t even work to set --x
to calc(-1*(50vw - 100%))
, we need to use calc(-1*(50vw - #{$d}))
instead.
$d: 4em;
$t: 1.5s;
.box { margin: .25*$d auto; width: $d; height: $d; --x: calc(-1*(50vw - #{$d})); transform: translate(var(--x)); background: #f90; animation: move $t ease-in-out infinite alternate;
}
@keyframes move { to { --x: calc(50vw - #{$d}); } }

For now, this only works in Blink browsers behind the Experimental Web Platform features flag. This can be enabled from chrome://flags
(or, if you’re using Opera, opera://flags
):

In all other browsers, we still see the flip at 50%
.
Applying this to our oscillating and scaling demo means we introduce two custom properties we register and animate—one is the translation amount along the x axis (--x
) and the other one is the uniform scaling factor (--f
).
CSS.registerProperty({ /* same as before */ });
CSS.registerProperty({ name: '--f', syntax: '<number>', initialValue: 1
});
The relevant CSS is as follows:
.box { --x: calc(-1*(50vw - #{$d})); transform: translate(var(--x)) scale(var(--f)); animation: move $t ease-in-out infinite alternate, size .5*$t ease-out infinite alternate;
}
@keyframes move { to { --x: calc(50vw - #{$d}); } }
@keyframes size { to { --f: .1 } }

Better Looking Stuff
A simple oscillating and scaling square isn’t the most exciting thing though, so let’s see nicer demos!

The 3D version
Going from 2D to 3D, the square becomes a cube and, since just one cube isn’t interesting enough, let’s have a whole grid of them!
We consider the body
to be our scene. In this scene, we have a 3D assembly of cubes (.a3d
). These cubes are distributed on a grid of nr
rows and nc
columns:
- var nr = 13, nc = 13;
- var n = nr*nc;
.a3d while n-- .cube - var n6hedron= 6; // cube always has 6 faces while n6hedron-- .cube__face
The first thing we do is a few basic styles to create a scene with a perspective, put the whole assembly in the middle and put each cube face into its place. We won’t be going into the details of how to build a CSS cube because I’ve already dedicated a very detailed article to this topic, so if you need a recap, check that one out!
The result so far can be seen below – all the cubes stacked up in the middle of the scene:

For all these cubes, their front half is in front of the plane of the screen and their back half is behind the plane of the screen. In the plane of the screen, we have a square section of our cube. This square is identical to the ones representing the cube faces.
See the Pen by thebabydino (@thebabydino) on CodePen.
Next, we set the column (--i
) and row (--j
) indices on groups of cubes. Initially, we set both these indices to 0
for all cubes.
.cube { --i: 0; --j: 0;
}
Since we have a number of cubes equal to the number of columns (nc
) on every row, we then set the row index to 1
for all cubes after the first nc
ones. Then, for all cubes after the first 2*nc
ones, we set the row index to 2
. And so on, until we’ve covered all nr
rows:
style | .cube:nth-child(n + #{1*nc + 1}) { --j: 1 } | .cube:nth-child(n + #{2*nc + 1}) { --j: 2 } //- and so on | .cube:nth-child(n + #{(nr - 1)*nc + 1}) { --j: #{nr - 1} }
We can compact this in a loop:
style - for(var i = 1; i < nr; i++) { | .cube:nth-child(n + #{i*nc + 1}) { --j: #{i} } -}
Afterwards, we move on to setting the column indices. For the columns, we always need to skip a number of cubes equal to nc - 1
before we encounter another cube with the same index. So, for every cube, the nc
-th cube after it is going to have the same index and we’re going to have nc
such groups of cubes.
(We only need to set the index to the last nc - 1
, because all cubes have the column index set to 0
initially, so we can skip the first group containing the cubes for which the column index is 0
– no need to set --i
again to the same value it already has.)
style | .cube:nth-child(#{nc}n + 2) { --i: 1 } | .cube:nth-child(#{nc}n + 3) { --i: 2 } //- and so on | .cube:nth-child(#{nc}n + #{nc}) { --i: #{nc - 1} }
This, too, can be compacted in a loop:
style - for(var i = 1; i < nc; i++) { | .cube:nth-child(#{nc}n + #{i + 1}) { --i: #{i} } -}
Now that we have all the row and column indices set, we can distribute these cubes on a 2D grid in the plane of the screen using a 2D translate()
transform, according to the illustration below, where each cube is represented by its square section in the plane of the screen and the distances are measured in between transform-origin
points (which are, by default, at 50% 50% 0
, so dead in the middle of the square cube sections from the plane of the screen):
/* $l is the cube edge length */
.cube { /* same as before */ --x: calc(var(--i)*#{$l}); --y: calc(var(--j)*#{$l}); transform: translate(var(--x), var(--y));
}
This gives us a grid, but it’s not in the middle of the screen.

Right now, it’s the central point of the top left cube that’s in the middle of the screen, as highlighted in the demo above. What we want is for the grid to be in the middle, meaning that we need to shift all cubes left and up (in the negative direction of both the x and y axes) by the horizontal and vertical differences between half the grid dimensions (calc(.5*var(--nc)*#{$l})
and calc(.5*var(--nr)*#{$l})
, respectively) and the distances between the top left corner of the grid and the midpoint of the top left cube’s vertical cross-section in the plane of the screen (these distances are each half the cube edge, or .5*$l
).
Subtracting these differences from the previous amounts, our code becomes:
.cube { /* same as before */ --x: calc(var(--i)*#{$l} - (.5*var(--nc)*#{$l} - .5*#{$l})); --y: calc(var(--j)*#{$l} - (.5*var(--nr)*#{$l} - .5*#{$l}));
}
Or even better:
.cube { /* same as before */ --x: calc((var(--i) - .5*(var(--nc) - 1))*#{$l})); --y: calc((var(--j) - .5*(var(--nr) - 1))*#{$l}));
}
We also need to make sure we set the --nc
and --nr
custom properties:
- var nr = 13, nc = 13;
- var n = nr*nc;
//- same as before
.a3d(style=`--nc: ${nc}; --nr: ${nr}`) //- same as before
This gives us a grid that’s in the middle of the viewport:

We’ve also made the cube edge length $l
smaller so that the grid fits within the viewport.
Alternatively, we can go for a CSS variable --l
instead so that we can control the edge length depending on the number of columns and rows. The first step here is setting the maximum of the two to a --nmax
variable:
- var nr = 13, nc = 13;
- var n = nr*nc;
//- same as before
.a3d(style=`--nc: ${nc}; --nr: ${nr}; --max: ${Math.max(nc, nr)}`) //- same as before
Then, we set the edge length (--l
) to something like 80%
(completely arbitrary value) of the minimum viewport dimension over this maximum (--max
):
.cube { /* same as before */ --l: calc(80vmin/var(--max));
}
Finally, we update the cube and face transforms, the face dimensions and margin
to use --l
instead of $l
:
.cube { /* same as before */ --l: calc(80vmin/var(--max)); --x: calc((var(--i) - .5*(var(--nc) - 1))*var(--l)); --y: calc((var(--j) - .5*(var(--nr) - 1))*var(--l)); &__face { /* same as before */ margin: calc(-.5*var(--l)); width: var(--l); height: var(--l); transform: rotate3d(var(--i), var(--j), 0, calc(var(--m, 1)*#{$ba4gon})) translatez(calc(.5*var(--l))); }
}
Now we have a nice responsive grid!

But it’s an ugly one, so let’s turn it into a pretty rainbow by making the color
of each cube depend on its column index (--i
):
.cube { /* same as before */ color: hsl(calc(var(--i)*360/var(--nc)), 65%, 65%);
}

We’ve also made the scene background dark so that we have better contrast with the now lighter cube edges.
To spice things up even further, we add a row rotation around the y axis depending on the row index (--j
):
.cube { /* same as before */ transform: rotateY(calc(var(--j)*90deg/var(--nr))) translate(var(--x), var(--y));
}

We’ve also decreased the cube edge length --l
and increased the perspective
value in order to allow this twisted grid to fit in.
Now comes the fun part! For every cube, we animate its position back and forth along the z axis by half the grid width (we make the translate()
a translate3d()
and use an additional custom property --z
that goes between calc(.5*var(--nc)*var(--l))
and calc(-.5*var(--nc)*var(--l))
) and its size (via a uniform scale3d()
of factor --f
that goes between 1
and .1
). This is pretty much the same thing we did for the square in our original example, except the motion now happens along the z axis, not along the x axis and the scaling happens in 3D, not just in 2D.
$t: 1s;
.cube { /* same as before */ --z: calc(var(--m)*.5*var(--nc)*var(--l)); transform: rotateY(calc(var(--j)*90deg/var(--nr))) translate3d(var(--x), var(--y), var(--z)) scale3d(var(--f), var(--f), var(--f)); animation: a $t ease-in-out infinite alternate; animation-name: move, zoom; animation-duration: $t, .5*$t;
}
@keyframes move { to { --m: -1 } }
@keyframes zoom { to { --f: .1 } }
This doesn’t do anything until we register the multiplier --m
and the scaling factor --f
to give them a type and an initial value:
CSS.registerProperty({ name: '--m', syntax: '<number>', initialValue: 1
});
CSS.registerProperty({ name: '--f', syntax: '<number>', initialValue: 1
});

At this point, all cubes animate at the same time. To make things more interesting, we add a delay that depends on both the column and row index:
animation-delay: calc((var(--i) + var(--j))*#{-2*$t}/(var(--nc) + var(--nr)));

The final touch is to add a rotation on the 3D assembly:
.a3d { top: 50%; left: 50%; animation: ry 8s linear infinite;
}
@keyframes ry { to { transform: rotateY(1turn); } }
We also make the faces opaque by giving them a black background and we have the final result:

The performance for this is pretty bad, as it can be seen from the GIF recording above, but it’s still interesting to see how far we can push things.
Hopping Square
I came across the original in a comment to another article and, as soon as I saw the code, I thought it was the perfect candidate for a makeover using some Houdini magic!
Let’s start by understanding what is happening in the original code.
In the HTML, we have nine divs.
</div> </div> </div> </div> </div> </div> </div>
Now, this animation is a lot more complex than anything I could ever come up with, but, even so, nine elements seems to be overkill. So let’s take a look at the CSS, see what they’re each used for and see how much we can simplify the code in preparation for switching to the Houdini-powered solution.
Let’s start with the animated elements. The .down
and .up
elements each have an animation
related to moving the square vertically:
/* original */
.down { position: relative; animation: down $duration ease-in infinite both; .up { animation: up $duration ease-in-out infinite both; /* the rest */ }
}
@keyframes down { 0% { transform: translateY(-100px); } 20%, 100% { transform: translateY(0); }
}
@keyframes up { 0%, 75% { transform: translateY(0); } 100% { transform: translateY(-100px); }
}
With @keyframes
and animations on both elements having the same duration, we can pull off a make-one-out-of-two trick.
In the case of the first set of @keyframes
, all the action (going from -100px
to 0
) happens in the [0%, 20%]
interval, while, in the case of the second one, all the action (going from 0
to -100px
) happens in the [75%, 100%]
interval. These two intervals don’t intersect. Because of this and because both animations have the same duration we can add up the translation values at each keyframe.
- at
0%
, we have-100px
from the first set of@keyframes
and0
from the second, which gives us-100px
- at
20%
, we have0
from the first set of@keyframes
and0
from the second (as we have0
for any frame from0%
to75%
), which gives us0
- at
75%
, we have0
from the first set of@keyframes
(as we have0
for any frame from20%
to100%
) and0
from the second, which gives us0
- at
100%
, we have0
from the first set of@keyframes
and-100px
from the second, which gives us-100px
Our new code is as follows. We have removed the animation-fill-mode
from the shorthand as it doesn’t do anything in this case since our animation
loops infinitely, has a non-zero duration and no delay:
/* new */
.jump { position: relative; transform: translateY(-100px); animation: jump $duration ease-in infinite; /* the rest */
}
@keyframes jump { 20%, 75% { transform: translateY(0); animation-timing-function: ease-in-out; }
}
Note that we have different timing functions for the two animations, so we need to switch between them in the @keyframes
. We still have the same effect, but we got rid of one element and one set of @keyframes
.
Next, we do the same thing for the .rotate-in
and .rotate-out
elements and their @keyframes
:
/* original */
.rotate-in { animation: rotate-in $duration ease-out infinite both; .rotate-out { animation: rotate-out $duration ease-in infinite both; }
}
@keyframes rotate-in { 0% { transform: rotate(-135deg); } 20%, 100% { transform: rotate(0deg); }
}
@keyframes rotate-out { 0%, 80% { transform: rotate(0); } 100% { transform: rotate(135deg); }
}
In a similar manner to the previous case, we add up the rotation values for each keyframe.
- at
0%
, we have-135deg
from the first set of@keyframes
and0deg
from the second, which gives us-135deg
- at
20%
, we have0deg
from the first set of@keyframes
and0deg
from the second (as we have0deg
for any frame from0%
to80%
), which gives us0deg
- at
80%
, we have0deg
from the first set of@keyframes
(as we have0deg
for any frame from20%
to100%
) and0deg
from the second, which gives us0deg
- at
100%
, we have0deg
from the first set of@keyframes
and135deg
from the second, which gives us135deg
This means we can compact things to:
/* new */
.rotate { transform: rotate(-135deg); animation: rotate $duration ease-out infinite;
}
@keyframes rotate { 20%, 80% { transform: rotate(0deg); animation-timing-function: ease-in; } 100% { transform: rotate(135deg); }
}
We only have one element with a scaling transform
that distorts our white square:
/* original */
.squeeze { transform-origin: 50% 100%; animation: squeeze $duration $easing infinite both;
}
@keyframes squeeze { 0%, 4% { transform: scale(1); } 45% { transform: scale(1.8, 0.4); } 100% { transform: scale(1); }
}
There’s not really much we can do here in terms of compacting the code, save for removing the animation-fill-mode
and grouping the 100%
keyframe with the 0%
and 4%
ones:
/* new */
.squeeze { transform-origin: 50% 100%; animation: squeeze $duration $easing infinite;
}
@keyframes squeeze { 0%, 4%, 100% { transform: scale(1); } 45% { transform: scale(1.8, .4); }
}
The innermost element (.square
) is only used to display the white box and has no transform
set on it.
/* original */
.square { width: 100px; height: 100px; background: #fff;
}
This means we can get rid of it if we move its styles to its parent element.
/* new */
$d: 6.25em;
.rotate { width: $d; height: $d; transform: rotate(-135deg); background: #fff; animation: rotate $duration ease-out infinite;
}
We got rid of three elements so far and our structure has become:
.frame .center .jump .squeeze .rotate .shadow
The outermost element (.frame
) serves as a scene or container. This is the big blue square.
/* original */
.frame { position: absolute; top: 50%; left: 50%; width: 400px; height: 400px; margin-top: -200px; margin-left: -200px; border-radius: 2px; box-shadow: 1px 2px 10px 0px rgba(0,0,0,0.2); overflow: hidden; background: #3498db; color: #fff; font-family: 'Open Sans', Helvetica, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
}
There’s no text in this demo, so we can get rid of the text-related properties. We can also get rid of the color
property since, not only do we not have text anywhere in this demo, but we’re also not using this for any borders, shadows, backgrounds (via currentColor
) and so on.
We can also avoid taking this containing element out of the document flow by using a flexbox layout on the body
. This also eliminates the offsets and the margin
properties.
/* new */
$s: 4*$d;
body { display: flex; align-items: center; justify-content: center; height: 100vh;
}
.frame { overflow: hidden; position: relative; width: $s; height: $s; border-radius: 2px; box-shadow: 1px 2px 10px rgba(#000, .2); background: #3498db;
}
We’ve also tied the dimensions of this element to those of the hopping square.
The .center
element is only used for positioning its direct children (.jump
and .shadow
), so we can take it out altogether and use the offsets on it directly on these children.
We use absolute positioning on all .frame
descendants. This makes the .jump
and .squeeze
elements 0x0
boxes, so we tweak the transform-origin
for the squeezing transform
(100%
of 0
is always 0
, but the value we want is half the square edge length .5*$d
). We also set a margin
of minus half the square edge length (-.5*$d
) on the .rotate
element (to compensate for the translate(-50%, -50%)
we had on the removed .center
element).
/* new */
.frame * { position: absolute, }
.jump { top: $top; left: $left; /* same as before */
}
.squeeze { transform-origin: 50% .5*$d; /* same as before */
}
.rotate { margin: -.5*$d; /* same as before */
}
Finally, let’s take a look at the .shadow
element.
/* original */
.shadow { position: absolute; z-index: -1; bottom: -2px; left: -4px; right: -4px; height: 2px; border-radius: 50%; background: rgba(0,0,0,0.2); box-shadow: 0 0 0px 8px rgba(0,0,0,0.2); animation: shadow $duration ease-in-out infinite both;
}
@keyframes shadow { 0%, 100% { transform: scaleX(.5); } 45%, 50% { transform: scaleX(1.8); }
}
We’re of course removing the position since we’ve already set that for all descendants of the .frame
. We can also get rid of the z-index
if we move the .shadow
before the .jump
element in the DOM.
Next, we have the offsets. The midpoint of the shadow is offset by $left
(just like the .jump
element) horizontally and by $top
plus half a square edge length (.5*$d
) vertically.
We see a height
that’s set to 2px
. Along the other axis, the width
computes to the square’s edge length ($d
) plus 4px
from the left
and 4px
from the right
. That’s plus 8px
in total. But one thing we notice is that the box-shadow
with an 8px
spread and no blur is just an extension of the background
. So we can just increase the dimensions of the our element by twice the spread along both axes and get rid of the box-shadow
altogether.
Just like in the case of the other elements, we also get rid of the animation-fill-mode
from the animation
shorthand:
/* new */
.shadow { margin: .5*($d - $sh-h) (-.5*$sh-w); width: $sh-w; height: $sh-h; border-radius: 50%; transform: scaleX(.5); background: rgba(#000, .2); animation: shadow $duration ease-in-out infinite;
}
@keyframes shadow { 45%, 50% { transform: scaleX(1.8); }
}
We’ve now reduced the code in the original demo by about 40% while still getting the same result.
See the Pen by thebabydino (@thebabydino) on CodePen.
Our next step is to merge the .jump
, .squeeze
and rotate
components into one, so that we go from three elements to a single one. Just as a reminder, the relevant styles we have at this point are:
.jump { transform: translateY(-100px); animation: jump $duration ease-in infinite;
}
.squeeze { transform-origin: 50% .5*$d; animation: squeeze $duration $easing infinite;
}
.rotate { transform: rotate(-135deg); animation: rotate $duration ease-out infinite;
}
@keyframes jump { 20%, 75% { transform: translateY(0); animation-timing-function: ease-in-out; }
}
@keyframes squeeze { 0%, 4%, 100% { transform: scale(1); } 45% { transform: scale(1.8, .4); }
}
@keyframes rotate { 20%, 80% { transform: rotate(0deg); animation-timing-function: ease-in; } 100% { transform: rotate(135deg); }
}
The only problem here is that the scaling transform
has a transform-origin
that’s different from the default 50% 50%
. Fortunately, we can go around that.
Any transform
with a transform-origin
different from the default is equivalent to a transform
chain with default transform-origin
that first translates the element such that its default transform-origin
point (the 50% 50%
point in the case of HTML elements and the 0 0
point of the viewBox
in the case of SVG elements) goes to the desired transform-origin
, applies the actual transformation we want (scaling, rotation, shearing, a combination of these… doesn’t matter) and then applies the reverse translation (the values for each of the axes of coordinates are multiplied by -1
).
transform
with a transform-origin
different from the default is equivalent to a chain that translates the point of the default transform-origin
to that of the custom one, performs the desired transform
and then reverses the initial translation (live demo).Putting this into code means that if we have any transform
with transform-origin: $x1 $y1
, the following two are equivalent:
/* transform on HTML element with transform-origin != default */
transform-origin: $x1 $y1;
transform: var(--transform); /* can be rotation, scaling, shearing */
/* equivalent transform chain on HTML element with default transform-origin */
transform: translate(calc(#{$x1} - 50%), calc(#{$y1} - 50%)) var(--transform) translate(calc(50% - #{$x1}), calc(50% - $y1);
In our particular case, we have the default transform-origin
value along the x axis, so we only need to perform a translation along the y axis. By also replacing the hardcoded values with variables, we get the following transform chain:
transform: translateY(var(--y)) translateY(.5*$d) scale(var(--fx), var(--fy)) translateY(-.5*$d) rotate(var(--az));
We can compact this a bit by joining the first two translations:
transform: translateY(calc(var(--y) + #{.5*$d})) scale(var(--fx), var(--fy)) translateY(-.5*$d) rotate(var(--az));
We also put the three animations on the three elements into just one:
animation: jump $duration ease-in infinite, squeeze $duration $easing infinite, rotate $duration ease-out infinite;
And we modify the @keyframes
so that we now animate the newly-introduced custom properties --y
, --fx
, --fy
and --az
:
@keyframes jump { 20%, 75% { --y: 0; animation-timing-function: ease-in-out; }
}
@keyframes squeeze { 0%, 4%, 100% { --fx: 1; --fy: 1 } 45% { --fx: 1.8; --fy: .4 }
}
@keyframes rotate { 20%, 80% { --az: 0deg; animation-timing-function: ease-in; } 100% { --az: 135deg }
}
However, this won’t work unless we register these CSS variables we have introduced and want to animate:
CSS.registerProperty({ 'name': '--y', 'syntax': '<length>', 'initialValue': '-100px'
});
CSS.registerProperty({ 'name': '--fx', 'syntax': '<number>', 'initialValue': 1
});
/* exactly the same for --fy */
CSS.registerProperty({ 'name': '--az', 'syntax': '<angle>', 'initialValue': '-135deg'
});
We now have a working demo of the method animating CSS variables. But given that our structure is now one wrapper with two children, we can reduce it further to one element and two pseudo-elements, thus getting the final version which can be seen below. It’s worth noting that this only works in Blink browsers with the Experimental Web Platform features flag enabled.

The post What Houdini Means for Animating Transforms appeared first on CSS-Tricks.