Let’s animate some CSS custom properties! This is a powerful way to approach CSS animations that would otherwise be tedious or impractical to implement.
Animate the What Now?
I’ll explain. Take this simple CSS animation, for example.
@keyframes slide-top {
from { translate: -100px; }
to { translate: 100px; }
}
.top-potato {
animation: slide-top 3s infinite alternate;
}
It moves something from left to right, then back, over and over. It works just fine, but we can rewrite it to use a CSS custom property instead.
@property --x {
syntax: '<length>';
inherits: true;
initial-value: 0px;
}
@keyframes slide-bottom {
from { --x: -100px; }
to { --x: 100px; }
}
.bottom-potato {
translate: var(--x);
animation: slide-bottom 3s infinite alternate;
}
The first thing we do is register an --x
custom property using @property
. This is important, since it enables the value of --x
to be animated. Then instead of animating translate
directly, we animate --x
and set translate
to var(--x)
.
And here are the results, with the “classic” approach on top and the CSS custom property approach on bottom.
See the Pen Simple Custom Property Animation by Will Boyd (@lonekorean) on CodePen.
You might be wondering why we went through all that trouble just to end up with the same animation. Fair point! This was just a basic example to show the concept and syntax.
From this point on, we’ll work through a series of increasingly interesting orbit animations and see how CSS custom properties help us craft them.
Simple Orbit Animation
Here’s a demo of a moon orbiting around a planet while staying upright. Take a look, then we’ll go through the CSS.
See the Pen Simple Orbit Animation by Will Boyd (@lonekorean) on CodePen.
At the heart of this animation is a custom property named --angle
that goes full circle from 0deg
to 360deg
. Since these are angle values, we need to make sure we register the custom property as an <angle>
.
@property --angle {
syntax: '<angle>';
inherits: true;
initial-value: 0deg;
}
@keyframes revolve {
from { --angle: 0deg; }
to { --angle: 360deg; }
}
We can use some basic trigonometry to turn that animated angle value into (x, y) coordinates that follow a perfectly circular path. Don’t worry if you’re not a trigonometrist (real word, believe it or not)! The formula for this is pretty easy.
x = cos(angle) * amplitude
y = sin(angle) * amplitude
Together, cos()
and sin()
create points along a circle with a radius of 1
. That’s pretty small (and unitless) which is where amplitude
comes in — we’ll use it to scale the circle up by some pixel value.
Now that we have the formula, let’s translate it into CSS and add some finishing touches.
.moon {
--amplitude: 150px;
--x: calc(cos(var(--angle)) * var(--amplitude));
--y: calc(sin(var(--angle)) * var(--amplitude));
translate: var(--x) var(--y);
animation: revolve 12s linear infinite;
}
It’s the same math discussed above, with --x
and --y
fed into the translate
property to position the orbiting moon. The animation
declaration gets --angle
moving, putting it all in motion.
Note that --angle
was registered via @property
but --x
and --y
were not. They didn’t need to be registered since their values were not being directly animated, just indirectly animated via --angle
.
Elliptical Orbit Animation
Let’s modify the animation to follow an elliptical path and add a bit of layering.
See the Pen Elliptical Orbit Animation by Will Boyd (@lonekorean) on CodePen.
We’ll keep --angle
from before, but add an animated --z
custom property, which we’ll use to control the z-index
of the moon to make it pass in front of or behind the planet.
We could just animate z-index
directly, but I’m leaning into the animated custom properties approach to show how it works and share a quirk I came across.
@property --angle {
syntax: '<angle>';
inherits: true;
initial-value: 0deg;
}
@property --z {
syntax: '<integer>';
inherits: true;
initial-value: 0;
}
@keyframes revolve {
from {
--angle: 0deg;
--z: -1;
}
to {
--angle: 360deg;
--z: 0;
}
}
--z
is registered as an <integer>
and animated from -1
(behind the planet) to 0
(in front of the planet). Integers in CSS are animated in whole number steps, meaning there are no intermediate decimal values. The value will flip at the midpoint of the animation, which is perfect since that’s when the moon will be off to the left of the planet.
The elliptical path is accomplished by decreasing the amplitude on the y-axis while keeping the same amplitude on the x-axis. So instead of a single --amplitude
, we’ll have an --x-amplitude
and a --y-amplitude
with different values.
Here are the updated styles for the moon.
.moon {
--x-amplitude: 150px;
--y-amplitude: 40px;
--x: calc(cos(var(--angle)) * var(--x-amplitude));
--y: calc(sin(var(--angle)) * var(--y-amplitude));
translate: var(--x) var(--y);
z-index: calc(var(--z)); /* calc() is for Safari */
animation: revolve 6s linear infinite;
}
Note that var(--z)
is wrapped in a calc()
. It shouldn’t be necessary to do that, but it fixes an issue in Safari with z-index
not taking the value. Why? I don’t know.
Multiple Elliptical Orbits Animation
Why stop with just a moon?
See the Pen Multiple Elliptical Orbits Animation by Will Boyd (@lonekorean) on CodePen.
This continues to build on things we’ve already figured out. The twist here is that the satellite and potato follow a tilted elliptical orbit. Here’s the CSS to make that happen.
.moon, .satellite, .potato {
--x-amplitude: 150px;
--y-amplitude: 40px;
--x: calc(cos(var(--angle)) * var(--x-amplitude));
--y: calc(sin(var(--angle)) * var(--y-amplitude));
transform:
rotate(var(--rotation))
translate(var(--x), var(--y))
rotate(calc(var(--rotation) * -1));
z-index: calc(var(--z)); /* calc() is for Safari */
animation: revolve 6s linear infinite;
}
Mostly familiar stuff in there, but the difference is we’ve replaced translate
with transform
to do a sequence of three transformations.
rotate
to tilt the orientation of the elliptical path by--rotation
degrees.translate
to position like before, but now within the rotated orientation.rotate
again the opposite way to undo image rotation (compare this potato to the potato in the very first demo — its orientation is the same).
The moon, satellite, and potato each have their own --rotation
. They also have staggered negative animation-delay
values, so they don’t crash into each other.
.moon {
--rotation: 0deg;
animation-delay: 0s;
}
.satellite {
--rotation: 60deg;
animation-delay: -2s;
}
.potato {
--rotation: 120deg;
animation-delay: -4s;
}
Multiple Wobbly Elliptical Orbits Animation
One more, just for fun. Let’s make it wobbly.
See the Pen Multiple Wobbly Elliptical Orbits Animation by Will Boyd (@lonekorean) on CodePen.
The CSS is the same as before, except with an updated formula for --x
and --y
.
.moon, .satellite, .potato {
--x-amplitude: 150px;
--y-amplitude: 40px;
--wobble-multiplier: 20;
--wobble-amplitude: 5px;
--x: calc(cos(var(--angle)) * var(--x-amplitude) + cos(var(--wobble-multiplier) * var(--angle)) * var(--wobble-amplitude));
--y: calc(sin(var(--angle)) * var(--y-amplitude) + sin(var(--wobble-multiplier) * var(--angle)) * var(--wobble-amplitude));
/* and all the other stuff from before */
}
I won’t go too deeply into the math, but basically it’s the same orbit formula as before but with another smaller and faster orbit added (literally, with a +
).
You know what’s great about this demo? Thanks to the animated custom property approach, all we needed to do was update the math for a single animation. No HTML changes to add extra elements, no keyframe animations added.
Performance
Currently, CSS custom property animations are always handled by the main thread, even if they’re used with properties that can be GPU-accelerated (like translate
and transform
). In other words, these animations won’t be quite as smooth. For more details, Bramus has a good explanation of the issue.
Something I’ve noticed in particular is a visible jankiness when text is animated via CSS custom properties. Take a look at this demo.
See the Pen Custom Property Text Animation by Will Boyd (@lonekorean) on CodePen.
If you look closely, you can see the word “chunky” is jumping pixel by pixel. It doesn’t have sub-pixel animation. It’s easier to see on a non-retina display.
However, I’ve found that using will-change
can smooth things out a bit (in Chrome and Safari at least, unfortunately not in Firefox). This is what I’m using to make the word “smooth” less jittery.
.smooth {
will-change: translate;
}
The animation is still running on the main thread, but at least we get sub-pixel animation to make it smoother.
Until Next Time
Using CSS custom properties for animation is incredibly useful because it allows us to compose animations in a sensible way. We’re able to pass animated values through a bit of math to create custom animations, without resorting to tedious keyframing or nesting multiple animated elements.
Anyway, hope you had fun making orbital potatoes with me.