https://yuanchuan.dev/time-based-css-animations 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: ""; 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); } Start / Stop 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); } Start / Stop 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))); } 8 FPS 24 FPS 60 FPS Start / Stop 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)); Start / Stop 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)); Start / Stop 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); Start / Stop sin() Or to make the movement back and forth. translate: calc(sin(mod(var(--t)/135, 270)) * 135px); Start / Stop 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); Start / Stop 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); Start / Stop 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))); @grid: 20x1 / 280px 60px; @gap: 1px; @size: 100% 20%; background: # 000; margin: auto; translate: 0 calc(20px * sin(4*@t(/20, +@i(*6), %360deg))); Start / Stop And it's quick to experiment new parameters too. translate: 0 calc(20px * sin(3*@t(/50, *@i(*2), %360deg))); @grid: 20x1 / 280px 60px; @gap: 1px; @size: 100% 20%; background: # 000; margin: auto; translate: 0 calc(20px * sin(3*@t(/50, *@i(*2), %360deg))); Start / Stop 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); @grid: 4x1 / 240px +.9; :container { border-radius: 50%; border: 4px solid #000; outline: 10px solid; outline-offset: 2px; background: @doodle( @grid: 12x1; @content: @I(- @i(-1)); @place: @plot(r: .8; dir: -90; rotate: -90;); font-size: 30px; font-family: sans-serif; font-weight: bold; ), @doodle( @grid: 60x1; background: #000; @size: @pn(16px, @m4(8px)) @pn(2px, @m4(1px)); @place: @plot(r: 1;); ); } @place: center; clip-path: polygon(50% -50%, 100% 65%, 0 65%); background: #000; @nth(3) { @size: 4px 90%; background: red; rotate: @TS(*6, %360deg); } @nth(2) { @size: 6px 70%; rotate: @TS(/60, *6, %360deg); } @nth(1) { @size: 10px 45%; rotate: @TS(/60, /12, *6, %360deg); } @nth(4) { @size: 12px; border-radius: 50%; clip-path: none; background: red; } 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); @grid: 4x1 / 240px +.9; :container { border-radius: 50%; border: 4px solid #000; outline: 10px solid; outline-offset: 2px; background: @doodle( @grid: 12x1; @content: @I(- @i(-1)); @place: @plot(r: .8; dir: -90; rotate: -90;); font-size: 30px; font-family: sans-serif; font-weight: bold; ), @doodle( @grid: 60x1; background: #000; @size: @pn(16px, @m4(8px)) @pn(2px, @m4(1px)); @place: @plot(r: 1;); ); } @place: center; clip-path: polygon(50% -50%, 100% 65%, 0 65%); background: #000; @nth(3) { @size: 4px 90%; background: red; rotate: round(down, @TS(*6, %360deg), 6deg); } @nth(2) { @size: 6px 70%; rotate: @TS(/60, *6, %360deg); } @nth(1) { @size: 10px 45%; rotate: @TS(/60, /12, *6, %360deg); } @nth(4) { @size: 12px; border-radius: 50%; clip-path: none; background: red; } One more example 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)))) ); @grid: 100x1 / 100% auto (4/3) / #10153e; @size: @rn(1vmin, 5vmin, 10); 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)))) ); margin: auto; border-radius: 50%; 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.