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.
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.