Baking physics in CSS via linear() abuse

· 13 min read

Check out the following CSS animations (touch to play):

these are CSS-based graphics and animations, you probably need to visit the website to see

If you wanted to implement complex procedural animations (like physics) on the web, you’d probably go for JS-based requestAnimationFrame update-loop and animate each frame. But everyone knows that CSS animations are much smoother than janky frame-based JS animations.

Native web animations run outside the main thread, so things like heavy JS computations won’t affect it. Likewise, it won’t interfere with your UI event handlers that decides the UI’s responsiveness.

JS
CSS

However, we can’t do procedural animations in CSS. CSS is declarative.

One way to do this is to precalculate the keyframes to replay with CSS, but a high quality replay would normally require a heavy amount of generated CSS to be loaded and parsed. I found a way to highly compress this precalculated CSS to a manageable size.

Technique

Baking precalculated animations in CSS keyframes is nothing new I suppose, but I wanted to highlight a ‘recent’ development in CSS, the linear() custom easing. Abusing this function enables high-fidelity CSS animations in a compact representation!

First, the linear() custom ease (not to be confused with the classic linear keyword with no arguments) lets you define custom easing curves.

div {
  animation: slide 2s linear(0, 0.5 60%, 0.25, 1) infinite alternate;
}
@keyframes slide {
  from { translate: 20px }
  to { translate: 120px }
}
div

It’s not very intuitive since we’re manually mapping values in the time domain (that’s what ease functions do). To illustrate what’s happening and to compare, we can denormalise the above example to classic keyframes that do the same thing:

div {
  animation: slide 2s linear infinite alternate;
}
@keyframes slide {
  0% { translate: 20px }
  60% { translate: 70px }
  80% { translate: 45px }
  100% { translate: 120px }
}
div

What linear() gives us is a compact form of defining single-property animations that have multiple keyframes, just like the above.

A more typical example can be seen below which would’ve taken hundreds of bytes to write in classic keyframes:

div {
  animation: wave 3s linear(0.5, 0.578, 0.655, 0.727, 0.794, 0.854, 0.905, 0.946, 0.976, 0.994, 1, 0.994, 0.976, 0.946, 0.905, 0.854, 0.794, 0.727, 0.655, 0.578, 0.5, 0.422, 0.345, 0.273, 0.206, 0.146, 0.095, 0.054, 0.024, 0.006, 0, 0.006, 0.024, 0.054, 0.095, 0.146, 0.206, 0.273, 0.345, 0.422, 0.5) infinite alternate;
}
@keyframes wave {
  from { translate: 0 0 }
  to { translate: 0 50px }
}
div

Above is a sampled sine wave. The range of positions over time (min and max) becomes the from and to values. At every sample, the position is recorded and normalised to [0,1] which becomes the linear() function’s values.

Note that the box starts in the middle of the range, not at from. The endpoints don’t have to be where the motion begins and ends.

Easing function as data

The main idea is we are no longer using the easing function to ease motion — how smoothly a thing goes from A to B. Think of it now as the actual motion being defined inside the easing function.

The linear() function contains the motion data. And the @keyframes declarations only give the absolute range that the motion data operates over.

So far we’ve only been working with one axis or degree of freedom. If we wanted a more interesting physics simulation, we’d need more dimensions, and a way to combine their animations.

Composition

For 2D animation, we need two separately-animated dimensions, but right now it’s written in a single property, translate: x y. Having two animations run on the same property would overwrite the other.

The general answer to this is animation-composition, but it forces the animation to calculate in the main thread, and we lose the main selling point of smooth animations.

One way out is to use two technically different properties for translation, translate and transform. Note that transformation properties are applied in this order: translate, rotate, scale, transform.

So x can use translate: $x 0 and y can use transform: translateY($y):

div {
  animation:
    circle-x 2s linear(1, 0.994, 0.976, 0.946, 0.905, 0.854, 0.794, 0.727, 0.655, 0.578, 0.5, 0.422, 0.345, 0.273, 0.206, 0.146, 0.095, 0.054, 0.024, 0.006, 0, 0.006, 0.024, 0.054, 0.095, 0.146, 0.206, 0.273, 0.345, 0.422, 0.5, 0.578, 0.655, 0.727, 0.794, 0.854, 0.905, 0.946, 0.976, 0.994, 1) infinite,
    circle-y 2s linear(0.5, 0.578, 0.655, 0.727, 0.794, 0.854, 0.905, 0.946, 0.976, 0.994, 1, 0.994, 0.976, 0.946, 0.905, 0.854, 0.794, 0.727, 0.655, 0.578, 0.5, 0.422, 0.345, 0.273, 0.206, 0.146, 0.095, 0.054, 0.024, 0.006, 0, 0.006, 0.024, 0.054, 0.095, 0.146, 0.206, 0.273, 0.345, 0.422, 0.5) infinite;
}
@keyframes circle-x {
  from { translate: 0 0 }
  to { translate: 100px 0 }
}
@keyframes circle-y {
  from { transform: translateY(0) }
  to { transform: translateY(100px) }
}
div

We can now animate two dimensions independently. Each linear() gives the motion data for the corresponding dimension.

transform is applied last, after rotate, which means adding a rotation dimension to the animation would cause issues.

The solution for sane rotate and scale animations is div wrappers: Apply translation to the parent element, and only apply rotation and scale to the child element.

Again, all of this is still possible with classic keyframes, but that would require explicitly writing both coordinates in every frame. In animations where x and y are mostly independent, that is highly inefficient. It’s a multiplicative gain in efficiency.

Reuse

Multiple elements can reuse the same @keyframes declarations. Since the only purpose of @keyframes now is to define the range of values, we can get the union of ranges over all elements so all elements can work within the same animation range — the same @keyframes declaration. Individual movements are still encoded in the respective linear().

.a {
  animation:
    bounce-x 2s linear(0, 1, 0.84) infinite,
    bounce-y 2s linear(0, 0.009, 0.028, 0.056, 0.094, 0.141, 0.197, 0.263, 0.338, 0.422, 0.516, 0.619, 0.731, 0.853, 0.984, 0.906, 0.81, 0.723, 0.646, 0.578, 0.52, 0.47, 0.43, 0.4, 0.379, 0.367, 0.365, 0.372, 0.388, 0.414, 0.449, 0.494, 0.548, 0.611, 0.684, 0.766, 0.857, 0.958, 0.949, 0.876, 0.812, 0.757, 0.712, 0.677, 0.65, 0.633, 0.626, 0.627, 0.638, 0.659, 0.689, 0.728, 0.777, 0.835, 0.902, 0.979, 0.951, 0.896, 0.85, 0.814, 0.787, 0.769, 0.76, 0.762, 0.772, 0.792, 0.821, 0.859, 0.907, 0.965, 0.977, 0.936, 0.905, 0.883, 0.87, 0.867, 0.874, 0.889, 0.914, 0.949, 0.992) infinite;
}
.b {
  animation:
    bounce-x 2s linear(1, 0 50%, 0) infinite,
    bounce-y 2s linear(0.5, 0.506, 0.517, 0.534, 0.556, 0.584, 0.618, 0.657, 0.703, 0.753, 0.809, 0.871, 0.939, 0.993, 0.955, 0.922, 0.895, 0.874, 0.858, 0.848, 0.843, 0.844, 0.851, 0.863, 0.881, 0.905, 0.934, 0.969, 0.994, 0.975, 0.962, 0.955, 0.953, 0.957, 0.966, 0.982, 0.999, 0.992, 0.991, 0.995, 1 50%, 1) infinite;
}
@keyframes bounce-x {
  from { translate: 0 0 }
  to { translate: 100px 0 }
}
@keyframes bounce-y {
  from { transform: translateY(0) }
  to { transform: translateY(100px) }
}
A
B

This is a big CSS efficiency win. We only need to name the specific CSS property / dimension a constant number of times, and the actual motion data per element is defined separately as pure numbers. Think of a @keyframes declaration as a channel with linear() as the data.

Baking

Directions
  1. Beat butter, sugar, and vanilla extract until incorporated.
  2. Beat in egg yolks.
  3. Beat in half of the sifted flour, then the milk.
  4. Beat or stir in the remainder of the flour until just incorporated.
  5. Pour the batter(s) into a cake tin.
  6. Bake at 175°C/350°F (conventional non-fan-forced) for 40-60min.

Oh, right. Baking the motion data. It depends on what your animation is, really. If it’s a simulation, then you record the state of each object in each time step. Only record the state that has corresponding CSS properties (or, design your simulation state for CSSability in the first place). After sampling state over time, it’s just mapping the values to the linear() abuse format.

To prepare for encoding, you’d need the total range of values for the @keyframes declarations. For each state $dimension you’re interested in, find the min and max values over the whole animation and use that in:

@keyframes $dimension {
  from { $property: $min }
  to { $property: $max }
}
Dimension can mean anything in your model that is animated independently, like x, y, color, rotation, scale, radius, progress, depth, etc.

To encode the linear() numbers: For every dimension, for every sample, normalise the property values against the range you got from earlier to [0,1]. Remember, one animation, one linear() per dimension!

for (const dimension of dimensions) {
  const normalized = dimension.samples.map(
    sample =>
      (sample - dimension.min) / (dimension.max - dimension.min)
  );
  css += `${dimension.name} linear(${normalized.join(", ")}),\n`;
}

The following is a demo that bakes your mouse movements into your very own linear() abuse CSS animation in real time.

drag me

CSS will appear here. Drag the draggy thing!

An optimisation could be added such that you skip writing samples that are too similar to the previous sample.

We let linear() do its actual job of linearly interpolating your values.

Skipping samples would break the property that each sample is in regular intervals, so you’d also need to add the progress percentage coordinates in linear() where necessary.

If your animation is more of the hand-authored type, then you’re just mapping keyframes to keyframes. This could probably be automated depending on the source animation authoring format. Any easing / motion curves from the source material would have to be ‘rasterised’ though, because we can only do linear interpolation with linear().

Alternatives considered / related tech

WAAPI. Web Animations API (WAAPI) is basically a JS API for creating CSS animations. It’s much better than dynamically constructing <style> tags. So I lied when I said JS-based animations can’t achieve native speeds (actually I didn’t, I specifically called out requestAnimationFrame only!).

offset-path is a strong alternative. It essentially lets you define an SVG-style path that an element could follow.

SVG animations? SMIL, <animateMotion>? Idk enough about these, but I imagine it’s verbose XML declarative animations.

CSS custom animatable @property. Apparently animating custom properties recalculates every frame in the main thread? So this isn’t very performant. This blog writes about it in detail.

Sprite sheets. Yeah sprite sheets.

prerendered video / animated WebP! This is the solution for 99% of cases! Forget everything you’ve learned in this blog, this is just some trifling for fun!

Demo code

Click a demo to see its CSS source code below the gallery!