Home

Time-based CSS Animations

05 May 2024

In my earlier post Time Uniform For CSS Animation, I took a note about a way to do CSS animations with time ticks instead of keyframes. It was limited applicable because CSS lacked the ability of doing complex Math calculations.

After years of wait, CSS now has enough Math functions supported, particularly mod(), round(), and trigonometric functions. It's time to revisit the time-based way of animation, hope it'll be more useful this time.

You may need to enable the Experimental feature flags to view demos in this page.

The basic idea

Using time for animation is very common in shader programs and various other places. CSS can not start a timer like JavaScript does, but nowadays it's possible to define a custom variable with the CSS Houdini API to track time in milliseconds.

@property --t {
  syntax: "<integer>";
  initial-value: 0;
  inherits: true
}
@keyframes tick {
  from { --t: 0 }
  to   { --t: 86400000 }
}
:root {
  animation: tick 86400000ms linear infinite
}

For each millisecond, the variable --t increments by 1, which is 1000 in one second. There's a trick to show the variable with counter() function.

::after {
  counter-reset: t var(--t);
  content: counter(t);
}

Other values which are based on --t will change along with it. That's how we get the animation effect.

div {
  /* 1 turn per second */
  rotate: calc(var(--t) * .001turn);
}

Controlling frame rate

Maintaining the update frequencey at 60 frames per second (FPS) is sufficient for a smooth animation. Browsers often have optimizations for rendering so there wouldn't be any problem if the frequencey is higher than 60 FPS. But one can manually control the frame rate using step() function if needed.

/* ... */

:root {
  animation: tick;
  animation-duration: 86400000ms;
  animation-iteration-count: infinite;

  /* 8 fps */
  animation-timing-function: step(calc(86400000/(1000/8)));

  /* 24 fps */
  animation-timing-function: step(calc(86400000/(1000/24)));

  /* 60 fps */
  animation-timing-function: step(calc(86400000/(1000/60)));
}

Transform time

The value of --t grows constantly in one direction. It's all right for the angle value that is bigger than 360deg, however, not all CSS properties treat their values as cyclical.

Let's say I want to animate a box from left to right, if the translate offset is associated to the --t it will increase constantly without stop.

translate: calc(var(--t) * .001px);

min()

One expected result is that when the offset reaches at a specific value, it stops immediately. This is how min() function can be useful.

translate: min(270px, calc(var(--t) * .5px));

To precisely control the animation duration we can restrict the value of --t instead.

/* 270px in 3s */
translate: calc(min(3000, var(--t)) * (270px / 3000));

mod()

After the box has moved to the right, another option is to restart the offset from beginning. Now we have mod() function to achieve this.

translate: calc(mod(var(--t)/4, 270) * 1px);

sin()

Or to make the movement back and forth.

translate: calc(sin(mod(var(--t)/135, 270)) * 135px);

Custom easing function

We can create custom easing functions using Math functions and the --t variable, which might not be achievable with cubic-bezier().

ease-out-cubic

The initial step is to bound the value --t between 0 and 1.

/* from 0 to 1 in 1s */
--t01: calc(min(1000, var(--t)) / 1000);

/* 1 - pow(1 - t, 3) */
--ease-out-cubic: calc(
  1 - pow(1 - var(--t01), 3)
);

translate: calc(var(--ease-out-cubic) * 270px);

ease-out-elastic

/* from 0 to 1 in 1s */
--t01: calc(min(1000, var(--t)) / 1000);

/* pow(2, -10t) * sin((10t - .75) * 2/3 * PI) + 1 */
--ease-out-elastic: calc(
  pow(2, -10 * var(--t01)) *
  sin((var(--t01) * 10 - .75) * 2/3 * PI) + 1
);

translate: calc(var(--ease-out-elastic) * 270px);

Experiment with CSS Doodle

As the expressions get complex, var() and calc() tend to make the code less readable. So I've added the @t function for representing variable --t. The latest version of css-doodle also accepts simple Math expressions directly inside arguments.

/* rotate: calc(mod(var(--t) / 1000, 10) * 5deg); */
rotate: @t(/1000, %10, *5deg);

Code is short without writing keyframes.

@grid: 20x1 / 280x 60px;
@gap: 1px;
@size: 100% 20%;
background: #000;
margin: auto;
translate: 0 calc(20px * sin(4*@t(/20, +@i(*6), %360deg)));

And it's quick to experiment new parameters too.

translate: 0 calc(20px * sin(3*@t(/50, *@i(*2), %360deg)));

Function @T and @TS

In addition to the @t function, the uppercase function @T represents another time ticks from beginning of the day. Function @TS is a shorthand for @t(/1000), which tracks time in second.

Here is a clock implemented in css-doodle. (CodePen link)

/* ... */

/* second */
rotate: @TS(*6, %360deg);

/* minute */
rotate: @TS(/60, *6, %360deg);

/* hour */
rotate: @TS(/60, /12, *6, %360deg);

round()

How can we make a jumping motion for the second hand? Of course, the direct approach is to use round() function, where the third parameter specifies the rounding interval. In the context of a clock, each step equals 360 / 60 = 6deg.

rotate: round(down, @TS(*6, %360deg), 6deg);

More examples

Animate colors and positions together. (CodePen link).

@grid: 100x1 / 100% auto (4/3) / #10153e;
@size: @rn(1vmin, 5vmin, 10);
margin: auto;
border-radius: 50%;

background: @p(
  hsl(@t(/10, +@i(*2), %360), 90%, 80%)
);
box-shadow: @m5(
  @r(±23vmin) @r(±23vmin) @r(2vmin) @r(-40px) @p
);
translate: @M2(
  calc(100px * tan(6*cos(@t(/10, +@i(*10), /6, %360deg))))
);

Animate text colors. (CodePen link).

@grid: 32 / 400px / #000 ^.95;
@content: @pn([a-z0-9.]);

--x: abs(@dx/@X);
--y: abs(@dy/@Y);

line-height: 0;
font-size: 12px;
font-family: monospace;
color: hsl($(480x), 100%, calc(tan($x/tan($y*4)*50 - @t(/400))*100%));

Conclusion

I'm excited about this approach. Although using keyframes seems much straightforward, for a demo scene full of math calculations and input variables, using time as a variable is more likely to get diverse results.

2024.05.17 update: Add text color animation demo.