Webhek

基于时间的 CSS 动画

05 May 2024

在我之前发表的文章CSS 动画的时间统一(Time Uniform For CSS Animation)中,我提到了一种用时间刻度代替keyframes来制作 CSS 动画的方法。由于 CSS 缺乏复杂的数学计算能力,因此适用范围有限。

经过多年的等待,CSS 现在已经支持足够多的数学函数,尤其是 mod(), round(), 和 三角函数。是时候重新审视基于时间的动画方式了,希望这次会更有用。

您可能需要启用 "实验 "功能标志 Experimental feature才能在此页面中查看演示。

基本理念

在着色器程序和其他各种程序中,使用时间来制作动画非常常见。CSS 无法像 JavaScript 那样启动计时器,但现在可以通过 CSS Houdini API 定义一个自定义变量,以毫秒为单位跟踪时间。

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

每过一毫秒,变量 --t 就会递增 1,即一秒内递增 1000。有一个使用 counter() 函数显示变量的技巧。

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

其他基于 --t 的值也会随之变化。这就是我们获得动画效果的方法。

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

控制帧频

将更新频率保持在每秒 60 帧(FPS)即可实现流畅的动画效果。浏览器通常会对渲染进行优化,因此更新频率高于 60 帧/秒不会有任何问题。但如果需要,也可以使用 step() 函数手动控制帧频。

/* ... */

: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)));
}

变化时间

--t的值不断向一个方向增长。对于角度值大于 360deg 的情况,这样做没有问题,但并非所有 CSS 属性都将其值视为周期性的。

比方说,我想把一个方框从左到右做成动画,如果平移偏移量与 --t 相关联,它就会不停地增加。

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

min()

一个预期的结果是,当偏移量达到一个特定值时,它会立即停止。这就是 min() 函数的作用所在。

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

为了精确控制动画的持续时间,我们可以限制 --t 的值。

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

mod()

在方框向右移动后,另一个选择是重新开始偏移。现在我们有了 mod() 函数来实现这一功能。

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

sin()

或者来回移动。

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

自定义 easing 函数

我们可以使用数学函数和 --t 变量创建自定义的 easing 函数,这可能是 cubic-bezier() 函数无法实现的。

ease-out-cubic

第一步是将 --t 值限定在 0 和 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);

尝试使用 CSS Doodle

随着表达式越来越复杂,var()calc() 往往会降低代码的可读性。因此,我添加了 @t 函数来表示变量--t。最新版本的 css-doodle 还可以直接在参数内接受简单的数学表达式。

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

不编写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)));

它还能快速试验新参数。

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

函数 @T and @TS

除了 @t 函数外,大写函数 @T 还表示从一天开始的另一个时间刻度。函数 @TS@t(/1000)的简写,用于跟踪以秒为单位的时间。

这是一个用 css-doodle 制作的时钟。(CodePen link)

/* ... */

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

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

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

round()

如何让秒针做跳跃运动呢?当然,直接的方法是使用 round() 函数,其中第三个参数指定了四舍五入的间隔。就时钟而言,每一步等于 360 / 60 = 6deg

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

再举一个例子

将颜色和位置一起制作成动画。 (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))))
);

结论

我对这种方法很感兴趣。虽然使用keyframes看起来更直接,但对于一个充满数学计算和输入变量的演示场景来说,使用时间作为变量更有可能获得多样化的结果。