在 CSS 中使用绝对值、符号、舍入和模运算

Avatar of Ana Tudor
Ana Tudor

DigitalOcean 为您的旅程每个阶段提供云产品。开始使用 $200 免费积分!

现在已经有一段时间了,CSS 规范中包含了许多非常有用的 数学函数,例如三角函数(sin()cos()tan()asin()acos()atan()atan2())、指数函数(pow()exp()sqrt()log()hypot())、符号相关函数(abs()sign())和步进值函数(round()mod()rem())。

但是,这些还没有在任何浏览器中实现,因此本文将展示如何使用我们已经拥有的 CSS 功能来计算 abs()sign()round()mod() 应该返回的值。然后我们将看看这让我们今天可以构建哪些酷炫的东西。

Screenshot collage - a 2x2 grid. The first one shows the items of a full-screen navigation sliding down with a delay that's proportional to the distance to the selected one. The second one shows a cube with each face made of neon tiles; these tiles shrink and go inwards, into the cube, with a delay that depends on the distance from the midlines of the top face. The third one is a time progress with a tooltip showing the elapsed time in a mm::ss format. The fourth one is a 3D rotating musical toy with wooden and metallic stars and a wooden crescent moon hanging from the top.
这些函数可以让我们做的一些事情。

请注意,这些技术从未打算在恐龙漫游互联网的那些日子里的浏览器中使用。其中一些甚至依赖于浏览器支持注册自定义属性(使用 @property)的能力,这意味着它们目前仅限于 Chromium。

计算出的等效值

--abs

我们可以使用新的 CSS max() 函数来获得它,该函数已经在所有主要浏览器的当前版本中实现。

假设我们有一个自定义属性 --a。我们不知道它是否为正数或负数,我们想要获得它的绝对值。我们通过选择此值与其 加法逆元 之间的最大值来实现。

--abs: max(var(--a), -1*var(--a));

如果 --a 为正数,则意味着它大于零,并且将其乘以 -1 会得到一个负数,该负数始终小于零。反过来,这总是小于正数 --a,因此 max() 返回的结果等于 var(--a)

如果 --a 为负数,则意味着它小于零,并且将其乘以 -1 会得到一个正数,该正数始终大于零,反过来,这总是大于负数 --a。因此,max() 返回的结果等于 -1*var(--a)

--sign

我们可以使用上一节的内容来获得它,因为一个数字的符号是该数字除以它的绝对值。

--abs: max(var(--a), -1*var(--a));
--sign: calc(var(--a)/var(--abs));

这里需要注意的是,这 *仅当 --a 无单位时才有效*,因为我们无法在 calc() 内除以带单位的数字。

此外,如果 --a0,则此解决方案仅在我们将 --sign(这仅在 Chromium 浏览器中受支持)注册为 initial-value0 时有效。

@property --sign {
  syntax: '<integer>';
  initial-value: 0;
  inherits: false /* or true depending on context */
}

这是因为 --a0,也使 --abs 计算为 0——并且在 CSS calc() 中除以 0 是无效的——因此我们需要确保 --sign 在这种情况下重置为 0。请记住,如果我们只是在 CSS 中将其设置为 0 而不是将其设置为 calc() 值,并且我们没有注册它,那么这种情况就不会发生。

--abs: max(var(--a), -1*var(--a));
--sign: 0; /* doesn't help */
--sign: calc(var(--a)/var(--abs));

在实践中,我经常使用以下版本用于整数。

--sign: clamp(-1, var(--a), 1);

这里,我们使用的是 clamp() 函数。它接受三个参数:一个最小允许值 -1,一个首选值 var(--a) 和一个最大允许值 1。返回的值是首选值,只要它在上下限之间,否则就是超过的限制。

如果 --a 是一个负整数,则意味着它小于或等于 -1,即 clamp() 函数的下限(或最小允许值),因此返回的值为 -1。如果它是一个正整数,则意味着它大于或等于 1,即 clamp() 函数的上限(或最大允许值),因此返回的值为 1。最后,如果 --a0,则它在上下限之间,因此该函数返回其值(在本例中为 0)。

这种方法的优点是更简单,不需要 Houdini 支持。也就是说,请注意它仅适用于无单位的值(将长度或角度值与 ±1 等整数进行比较就像比较苹果和橘子——它不起作用!),这些值要么恰好为 0,要么绝对值为 1 或更大。对于子单位值,例如 -.05,我们上面的方法失败,因为返回的值为 -.05,而不是 -1

我最初的想法是,我们可以通过引入一个小于我们知道 --a 可能取的最小非零值的极限值来将这种技术扩展到子单位值。例如,假设我们的极限是 .000001——这将使我们能够正确地获得 -.05 的符号为 -1,以及 .0001 的符号为 1

--lim: .000001;
--sign: clamp(-1*var(--lim), var(--a), var(--lim));

Temani Afif 建议了一个更简单的版本,它将 --a 乘以一个非常大的数字,以产生一个超单位值。

--sign: clamp(-1, var(--a)*10000, 1);

我最终选择将 --a 除以极限值,因为看到它不会低于的最小非零值更直观。

--lim: .000001;
--sign: clamp(-1, var(--a)/var(--lim), 1);

--round(以及 --ceil--floor

这是一个我卡了很长时间的问题,直到我从 Christian Schaefer 那里得到了一个类似问题的巧妙建议。就像符号的情况一样,这仅适用于无单位的值,并且需要将 --round 变量注册为 <integer>,以便我们对我们设置的值强制执行舍入。

@property --round {
  syntax: '<integer>';
  initial-value: 0;
  inherits: false /* or true depending on context */
}

.my-elem { --round: var(--a); }

通过扩展,如果我们减去或加上 .5,我们可以获得 --floor--ceil

@property --floor {
  syntax: '<integer>';
  initial-value: 0;
  inherits: false /* or true depending on context */
}

@property --ceil {
  syntax: '<integer>';
  initial-value: 0;
  inherits: false /* or true depending on context */
}

.my-elem {
  --floor: calc(var(--a) - .5);
  --ceil: calc(var(--a) + .5)
}

--mod

它基于 --floor 技术来获取整数商,然后让我们获得模值。这意味着我们的两个值都必须是无单位的。

@property --floor {
  syntax: '<integer>';
  initial-value: 0;
  inherits: false /* or true depending on context */
}

.my-elem {
  --floor: calc(var(--a)/var(--b) - .5);
  --mod: calc(var(--a) - var(--b)*var(--floor))
}

用例

我们用这种技术可以做哪些事情?让我们仔细看看三个用例。

错落动画中轻松对称(不仅仅是动画)

虽然绝对值可以帮助我们获得许多属性的对称结果,但 animation-delaytransition-delay 是我使用得最多的属性,所以让我们看看一些例子吧!

我们在一个容器中放置 --n 个项目,每个项目都有一个索引 --i--n--i 都是我们通过 style 属性传递给 CSS 的变量。

- let n = 16;

.wrap(style=`--n: ${n}`)
  - for(let i = 0; i < n; i++)
    .item(style=`--i: ${i}`)

这将提供以下编译后的 HTML

<div class='wrap' style='--n: 16'>
  <div class='item' style='--i: 0'></div>
  <div class='item' style='--i: 1'></div>
  <!-- more such items -->
</div>

我们设置了一些样式,以便项目以一行排列,并且是边长不为零的正方形。

$r: 2.5vw;

.wrap {
  display: flex;
  justify-content: space-evenly;
}

.item { padding: $r; }
Screenshot showing the items lined in a row and DevTools with the HTML structure and the styles applied.
目前的结果。

现在我们添加两组关键帧来动画化一个缩放的 `transform` 和一个 `box-shadow`。第一组关键帧 `grow` 使我们的项目从 `0%` 的无到 `50%` 的全尺寸缩放,之后它们保持全尺寸直到结束。第二组关键帧 `melt` 向我们展示了项目具有 `inset` 盒阴影,这些阴影在 `animation` 的中间点(`50%`)之前完全覆盖它们。这也是项目在从无到有增长后达到全尺寸的时候。然后这些 `inset` 阴影的扩散半径缩小,直到在 `100%` 时缩小到无。

$r: 2.5vw;

.item {
  padding: $r;
  animation: a $t infinite;
  animation-name: grow, melt;
}

@keyframes grow {
  0% { transform: scale(0); }
  50%, 100% { transform: none; }
}

@keyframes melt {
  0%, 50% { box-shadow: inset 0 0 0 $r; }
  100% { box-shadow: inset 0 0; }
}
Animated gif. Shows 16 black square tiles in a row growing from nothing to full size, then melting from the inside until they disappear. The cycle then repeats. In this case, all tiles animate at the same time.
基本动画(在线演示)。

现在是有趣的部分!我们计算第一个项目索引和最后一个项目索引之间的中间值。这是两个的 算术 平均值(因为我们的索引是基于零的,第一个和最后一个分别是 `0` 和 `n - 1`)。

--m: calc(.5*(var(--n) - 1));

我们得到这个中间值 `--m` 和项目索引 `--i` 之差的绝对值 `--abs`,然后用它来计算 `animation-delay`。

--abs: max(var(--m) - var(--i), var(--i) - var(--m));
animation: a $t calc(var(--abs)/var(--m)*#{$t}) infinite backwards;
animation-name: grow, melt;

中间值 `--m` 和项目索引 `--i` 之差的绝对值 `--abs` 可以小到 `0`(对于中间项目,如果 `--n` 是奇数)和大到 `--m`(对于末端项目)。这意味着用 `--m` 除以它总是给我们一个 `[0, 1]` 区间内的值,然后我们用动画持续时间 `$t` 乘以它,以确保每个项目的延迟都在 `0s` 和 `animation-duration` 之间。

请注意,我们还将 `animation-fill-mode` 设置为 `backwards`。由于大多数项目会稍后开始动画,因此这告诉浏览器在它们开始动画之前,将它们保持在 `0%` 关键帧中的样式。

在这种特殊情况下,如果没有它,我们也不会看到任何区别,因为虽然项目会处于全尺寸(不像在 `grow` 动画的 `0%` 关键帧中缩放到无),但它们也会没有 `box-shadow` 直到它们开始动画。但是,在很多其他情况下,它确实会产生差异,我们不应该忘记它。

另一种可能性(不涉及设置 `animation-fill-mode`)将是确保 `animation-delay` 始终小于或最多等于 `0`,方法是从中减去一个完整的 `animation-duration`。

--abs: max(var(--m) - var(--i), var(--i) - var(--m));
animation: a $t calc((var(--abs)/var(--m) - 1)*#{$t}) infinite;
animation-name: grow, melt;

两种选择都是有效的,你选择哪一种取决于你希望一开始发生什么。我通常倾向于使用负延迟,因为当记录循环动画以制作 gif(如以下示例所示)时,它们更有意义,这说明了 `animation-delay` 值相对于中间值的对称性。

Animated gif. Shows 16 black square tiles in a row, each of them growing from nothing to full size, then melting from the inside until they disappear, with the cycle then repeating. Only now, they don't all animate at the same time. The closer they are to the middle, the sooner they start their animation, those at the very ends of the row being one full cycle behind those in the very middle.
交错循环动画。

为了直观比较这两个选项,您可以重新运行以下演示,看看一开始会发生什么。

更高级的例子如下

导航链接向上滑动,然后向下滑动,延迟与它们离选中链接的距离成正比。

这里,每一个 `--n` 导航链接和相应的食谱文章都有一个索引 `--idx`。每当鼠标悬停在导航链接上或聚焦到导航链接上时,它的 `--idx` 值就会被读取并设置为 `body` 上的当前索引 `--k`。如果这些项目都没有被鼠标悬停或聚焦,`--k` 会被设置为 `[0, n)` 区间之外的值(例如 `-1`)。

`--k` 和链接索引 `--idx` 之差的绝对值 `--abs` 可以告诉我们这是否是当前选中的(鼠标悬停或聚焦的)项目。如果这个绝对值为 `0`,那么我们的项目就是当前选中的项目(即 `--not-sel` 为 `0`,`--sel` 为 `1`)。如果这个绝对值大于 `0`,那么我们的项目就不是当前选中的项目(即 `--not-sel` 为 `1`,`--sel` 为 `0`)。

鉴于 `--idx` 和 `--k` 都是整数,它们的差也是整数。这意味着这个差的绝对值 `--abs` 或者是 `0`(当项目被选中时),或者是大于或等于 `1`(当项目没有被选中时)。

当我们将所有这些都放到代码中时,我们得到了以下结果

--abs: Max(var(--k) - var(--idx), var(--idx) - var(--k));
--not-sel: Min(1, var(--abs));
--sel: calc(1 - var(--not-sel));

属性 `--sel` 和 `--not-sel`(它们始终是整数,并且始终加起来等于 `1`)决定了导航链接的大小(在宽屏场景中是 `width`,在窄屏场景中是 `height`),它们是否灰度化以及它们的文本内容是否隐藏。这些内容我们这里不再深入探讨,因为它超出了本文的范围,我已经在 之前的一篇文章 中详细解释过。

这里相关的是,当单击导航链接时,它会从视线中滑出(在宽屏情况下向上,在窄屏情况下向左),然后是周围的所有其他链接,每个链接都有一个 `transition-delay`,该延迟取决于它们离被单击的链接的距离(即,它们索引 `--idx` 和当前选中项目索引 `--k` 之差的绝对值 `--abs`),从而显示出相应的食谱文章。这些 `transition-delay` 值相对于当前选中项目是对称的。

transition: transform 1s calc(var(--abs)*.05s);

实际的过渡和延迟实际上更复杂,因为除了 `transform` 之外,还有更多属性被动画化,特别是对于 `transform`,在从食谱 `article` 返回到导航链接时,会有额外的延迟,因为我们在让链接向下滑动之前,要等待 `<article>` 元素消失。但我们感兴趣的是延迟的组成部分,它使链接更靠近选中的链接,并在那些离得更远的链接之前开始从视线中滑出。这就像上面使用 `--abs` 变量计算的那样。

您可以使用下面的交互式演示进行操作。

在 2D 中事情变得更加有趣,所以现在让我们将我们的行变成网格!

我们首先改变一下结构,使我们有 `8` 列和 `8` 行(这意味着我们在网格上总共有 `8·8 = 64` 个项目)。

- let n = 8;
- let m = n*n;

style
  - for(let i = 0; i < n; i++)
    | .item:nth-child(#{n}n + #{i + 1}) { --i: #{i} }
    | .item:nth-child(n + #{n*i + 1}) { --j: #{i} }
.wrap(style=`--n: ${n}`)
  - for(let i = 0; i < m; i++)
    .item

上面的 Pug 代码编译成以下 HTML

<style>
  .item:nth-child(8n + 1) { --i: 0 } /* items on 1st column */
  .item:nth-child(n + 1) { --j: 0 } /* items starting from 1st row */
  .item:nth-child(8n + 2) { --i: 1 } /* items on 2nd column */
  .item:nth-child(n + 9) { --j: 1 } /* items starting from 2nd row */
  /* 6 more such pairs */
</style>
<div class='wrap' style='--n: 8'>
  <div class='item'></div>
  <div class='item'></div>
  <!-- 62 more such items -->
</div>

就像前面的情况一样,我们计算一个中间索引 `--m`,但由于我们已经从 1D 转换到了 2D,所以现在我们有两个绝对值的差要计算,每个维度一个(一个用于列,`--abs-i`,一个用于行,`--abs-j`)。

--m: calc(.5*(var(--n) - 1));
--abs-i: max(var(--m) - var(--i), var(--i) - var(--m));
--abs-j: max(var(--m) - var(--j), var(--j) - var(--m));

我们使用完全相同的两组 `@keyframes`,但 `animation-delay` 有所改变,所以它依赖于 `--abs-i` 和 `--abs-j`。这些绝对值可以小到 `0`(对于位于列和行的正中间的图块)和大到 `--m`(对于位于列和行末端的图块),这意味着它们中的任何一个与 `--m` 之比始终在 `[0, 1]` 区间内。这意味着这两个比率的总和始终在 `[0, 2]` 区间内。如果我们想将其减少到 `[0, 1]` 区间,我们需要用 `2` 除以它(或者乘以 `0.5`,是一样的)。

animation-delay: calc(.5*(var(--abs-i)/var(--m) + var(--abs-j)/var(--m))*#{$t});

这将给我们 `[0s, $t]` 区间内的延迟。我们可以从括号中取出分母 `var(--m)` 来稍微简化上面的公式

animation-delay: calc(.5*(var(--abs-i) + var(--abs-j))/var(--m)*#{$t});

就像前面的情况一样,这使得网格项目越远离网格的中间位置,它们开始动画的时间就越晚。我们应该使用 `animation-fill-mode: backwards` 来确保它们在延迟时间过去并开始动画之前,保持在 `0%` 关键帧中指定的狀態。

或者,我们可以从所有延迟中减去一个动画持续时间 `$t`,以确保所有网格项目在页面加载时都已经开始它们的 `animation`。

animation-delay: calc((.5*(var(--abs-i) + var(--abs-j))/var(--m) - 1)*#{$t});

这将给我们以下结果

Animated gif. Shows an 8x8 grid of tiles, each of them growing from nothing to full size, then melting from the inside until they disappear, with the cycle then repeating. The smaller the sum of their distances to the middle is, the sooner they start their animation, those at the very corners of the grid being one full cycle behind those in the very middle.
交错 2D 动画(在线演示)。

现在让我们看看一些更有趣的例子。我们不会详细介绍它们背后的“如何”,因为对称值技术的工作原理与前面的例子完全相同,而其他内容超出了本文的范围。但是,每个示例的标题中都有一个指向 CodePen 演示的链接,大多数这些 Pens 也有一个录制视频,展示了我从头开始编写代码的过程。

在第一个例子中,每个网格项目由两个三角形组成,它们沿着它们相遇的对角线向相反方向缩小到无,然后恢复到全尺寸。由于这是一个交替的 `animation`,我们让延迟跨越两个迭代(一个正常迭代和一个反转迭代),这意味着我们不再将比率总和除以二,并且我们减去 `2` 以确保每个项目的延迟都是负数。

animation: s $t ease-in-out infinite alternate;
animation-delay: calc(((var(--abs-i) + var(--abs-j))/var(--m) - 2)*#{$t});
网格波浪:脉冲三角形(在线演示

在第二个例子中,每个网格项目都有一个角度的渐变,该渐变从 `0deg` 动画到 `1turn`。这可以通过 Houdini 实现,如 这篇文章 关于使用 CSS 动画渐变状态中所解释的那样。

场波浪:单元格渐变旋转(在线演示

第三个例子非常相似,只是动画角度被用在 `conic-gradient` 而不是线性渐变中,也被用在第一个停止点的色相中。

彩虹小时波浪(在线演示

在第四个例子中,每个网格单元格包含 7 个彩虹点,它们上下振荡。振荡延迟有一个组件,它依赖于单元格索引,其方式与前面的网格完全相同(唯一不同的是列数不同于行数,所以我们需要计算两个中间索引,每个维度一个),以及一个组件,它依赖于点索引 `--idx` 相对于每个单元格的点数 `--n-dots`。

--k: calc(var(--idx)/var(--n-dots));
--mi: calc(.5*(var(--n-cols) - 1));
--abs-i: max(var(--mi) - var(--i), var(--i) - var(--mi));
--mj: calc(.5*(var(--n-rows) - 1));
--abs-j: max(var(--mj) - var(--j), var(--j) - var(--mj));
animation-delay: 
  calc((var(--abs-i)/var(--mi) + var(--abs-j)/var(--mj) + var(--k) - 3)*#{$t});
彩虹点波浪:点振荡(在线演示

在第五个例子中,构成立方体面的图块收缩并向内移动。顶面的 `animation-delay` 的计算方式与我们的第一个 2D 演示完全相同。

呼吸我:霓虹瀑布(在线演示之前的迭代

在第六个例子中,我们有一个由列组成的网格,它们上下振荡。

列波浪(在线演示

`animation-delay` 不是我们唯一可以设置为具有对称值的属性。我们也可以对项目的尺寸进行这种设置。在下面的第七个例子中,图块分布在从垂直轴 (y) 开始的六个环周围,并且使用一个因子进行缩放,该因子取决于它们离环的最高点的距离。这基本上是 1D 案例,只是轴在圆上弯曲。

圆形网格熔化 (实时演示)

第八个示例展示了十个装饰品手臂,它们环绕一个大球体。这些装饰品的大小取决于它们离两极的距离,离两极最近的装饰品最小。这是通过计算手臂上点的中点索引--m,以及它与当前装饰品索引--j之间的差的绝对值--abs,然后使用该绝对值与中点索引的比率来获取大小因子--f来完成的,我们随后在设置padding时使用该因子。

--m: calc(.5*(var(--n-dots) - 1));
--abs: max(var(--m) - var(--j), var(--j) - var(--m));
--f: calc(1.05 - var(--abs)/var(--m));
padding: calc(var(--f)*#{$r});
在球体内部移动 (实时演示)

某些(选中或中间)项目之前和之后的项目不同样式

假设我们有一组单选按钮和标签,这些标签的索引设置为自定义属性--i。我们希望选中项之前的标签具有绿色背景,选中项的标签具有蓝色背景,其余标签为灰色。在body中,我们将当前选中选项的索引设置为另一个自定义属性--k

- let n = 8;
- let k = Math.round((n - 1)*Math.random());

body(style=`--k: ${k}`)
  - for(let i = 0; i < n; i++)
    - let id = `r${i}`;
    input(type='radio' name='r' id=id checked=i===k)
    label(for=id style=`--i: ${i}`) Option ##{i}

这编译成以下 HTML

<body style='--k: 1'>
  <input type='radio' name='r' id='r0'/>
  <label for='r0' style='--i: 0'>Option #0</label>
  <input type='radio' name='r' id='r1' checked='checked'/>
  <label for='r1' style='--i: 1'>Option #1</label>
  <input type='radio' name='r' id='r2'/>
  <label for='r2' style='--i: 2'>Option #2</label>
  <!-- more options -->
</body>

我们设置了一些布局和美化样式,包括标签上的渐变background,它创建了三个垂直条纹,每个条纹占据background-size的三分之一(目前,它只是默认的100%,即元素的完整宽度)

$c: #6daa7e, #335f7c, #6a6d6b;

body {
  display: grid;
  grid-gap: .25em 0;
  grid-template-columns: repeat(2, max-content);
  align-items: center;
  font: 1.25em/ 1.5 ubuntu, trebuchet ms, sans-serif;
}

label {
  padding: 0 .25em;
  background: 
    linear-gradient(90deg, 
      nth($c, 1) 33.333%, 
      nth($c, 2) 0 66.667%, 
      nth($c, 3) 0);
  color: #fff;
  cursor: pointer;
}
Screenshot showing radio inputs and their labels on two grid columns. The labels have a vertical three stripe background with the first stripe being green, the second one blue and the last one grey.
目前的结果。

从 JavaScript 中,我们会在每次选择不同的选项时更新--k的值

addEventListener('change', e => {
  let _t = e.target;
	
  document.body.style.setProperty('--k', +_t.id.replace('r', ''))
})

现在是最有趣的部分!对于我们的label元素,我们计算标签索引--i与当前选中选项索引--k之间的差的符号--sgn。然后,我们使用该--sgn值来计算background-position,当background-size设置为300%时 - 也就是说,标签width的三倍,因为我们可能有三种可能的背景:一种用于标签是选中项之前选项的情况,第二种用于标签是选中选项的情况,第三种用于标签是选中项之后选项的情况。

--sgn: clamp(-1, var(--i) - var(--k), 1);
background: 
  linear-gradient(90deg, 
      nth($c, 1) 33.333%, 
      nth($c, 2) 0 66.667%, 
      nth($c, 3) 0) 
    calc(50%*(1 + var(--sgn)))/ 300%

如果--i小于--k(标签是选中项之前选项的情况),则--sgn-1,并且background-position计算为50%*(1 + -1) = 50%*0 = 0%,这意味着我们只看到第一个垂直条纹(绿色条纹)。

如果--i等于--k(标签是选中选项的情况),则--sgn0,并且background-position计算为50%*(1 + 0) = 50%*1 = 50%,因此我们只看到中间的垂直条纹(蓝色条纹)。

如果--i大于--k(标签是选中项之后选项的情况),则--sgn1,并且background-position计算为50%*(1 + 1) = 50%*2 = 100%,这意味着我们只看到最后一个垂直条纹(灰色条纹)。

一个更美观示例如下所示,导航栏中垂直条纹位于最靠近选中选项的一侧,而对于选中选项,垂直条纹会扩展到整个元素。

它使用了与之前演示类似的结构,包含单选输入和导航项的标签。移动的“背景”实际上是一个::after伪元素,其平移值取决于符号--sgn。文本是一个::before伪元素,其位置应该在白色区域的中间,因此其平移值也取决于--sgn

/* relevant styles */
label {
  --sgn: clamp(-1, var(--k) - var(--i), 1);
  
  &::before {
    transform: translate(calc(var(--sgn)*-.5*#{$pad}))
  }
  &::after {
    transform: translate(calc(var(--sgn)*(100% - #{$pad})))
  }
}

现在,让我们快速浏览几个更多演示,这些演示中计算符号(以及可能还有绝对值)非常有用。

首先,我们有一个具有radial-gradient的方形网格单元格,其半径从覆盖整个单元格缩小到无。该animation的延迟如上一节所述计算。这里的新内容是radial-gradient圆的坐标取决于单元格在网格中间的位置 - 也就是说,取决于列--i和行--j索引与中间索引--m之间差值的符号。

/* relevant CSS */
$t: 2s;

@property --p {
  syntax: '<length-percentage>';
  initial-value: -1px;
  inherits: false;
}

.cell {
  --m: calc(.5*(var(--n) - 1));
  --dif-i: calc(var(--m) - var(--i));
  --abs-i: max(var(--dif-i), -1*var(--dif-i));
  --sgn-i: clamp(-1, var(--dif-i)/.5, 1);
  --dif-j: calc(var(--m) - var(--j));
  --abs-j: max(var(--dif-j), -1*var(--dif-j));
  --sgn-j: clamp(-1, var(--dif-j)/.5, 1);
  background: 
    radial-gradient(circle
      at calc(50% + 50%*var(--sgn-i)) calc(50% + 50%*var(--sgn-j)), 
      currentcolor var(--p), transparent calc(var(--p) + 1px))
      nth($c, 2);
  animation-delay: 
    calc((.5*(var(--abs-i) + var(--abs-j))/var(--m) - 1)*#{$t});
}

@keyframes p { 0% { --p: 100%; } }
下沉的感觉 (实时演示)

然后我们有一个双螺旋的小球体,球体直径--d和确定球体位置的径向距离--x都取决于每个球体的索引--i与中间索引--m之间差值的绝对值--abs。该差值的符号--sgn用于确定螺旋旋转方向。这取决于每个球体在中间的位置 - 也就是说,其索引--i是否小于或大于中间索引--m

/* relevant styles */
--m: calc(.5*(var(--p) - 1));
--abs: max(calc(var(--m) - var(--i)), calc(var(--i) - var(--m)));
--sgn: clamp(-1, var(--i) - var(--m), 1);
--d: calc(3px + var(--abs)/var(--p)*#{$d}); /* sphere diameter */
--a: calc(var(--k)*1turn/var(--n-dot)); /* angle used to determine sphere position */
--x: calc(var(--abs)*2*#{$d}/var(--n-dot)); /* how far from spiral axis */
--z: calc((var(--i) - var(--m))*2*#{$d}/var(--n-dot)); /* position with respect to screen plane */
width: var(--d); height: var(--d);
transform: 
  /* change rotation direction by changing x axis direction */
  scalex(var(--sgn)) 
  rotate(var(--a)) 
  translate3d(var(--x), 0, var(--z)) 
  /* reverse rotation so the sphere is always seen from the front */
  rotate(calc(-1*var(--a))); 
  /* reverse scaling so lighting on sphere looks consistent */
  scalex(var(--sgn))
无透视 (实时演示)

最后,我们有一个带有border的非方形盒子网格。这些盒子有一个使用conic-gradient创建的mask,其起始角度--ang是动画的。这些盒子是水平翻转还是垂直翻转取决于它们在中间的位置 - 也就是说,取决于列--i和行--j索引与中间索引--m之间差值的符号。animation-delay取决于这些差值的绝对值,并如上一节所述计算。我们还有更漂亮“蠕虫”外观的gooey filter,但我们这里不介绍。

/* relevant CSS */
$t: 1s;

@property --ang {
  syntax: '<angle>';
  initial-value: 0deg;
  inherits: false;
}

.box {
  --m: calc(.5*(var(--n) - 1));
  --dif-i: calc(var(--i) - var(--m));
  --dif-j: calc(var(--j) - var(--m));
  --abs-i: max(var(--dif-i), -1*var(--dif-i));
  --abs-j: max(var(--dif-j), -1*var(--dif-j));
  --sgn-i: clamp(-1, 2*var(--dif-i), 1);
  --sgn-j: clamp(-1, 2*var(--dif-j), 1);
  transform: scale(var(--sgn-i), var(--sgn-j));
  mask:
    repeating-conic-gradient(from var(--ang, 0deg), 
        red 0% 12.5%, transparent 0% 50%);
  animation: ang $t ease-in-out infinite;
  animation-delay: 
    calc(((var(--abs-i) + var(--abs-j))/var(--n) - 1)*#{$t});
}

@keyframes ang { to { --ang: .5turn; } }
被蠕虫吞噬 (实时演示)

时间(不仅仅是)格式

假设我们有一个元素,我们为其存储了一个秒数,以自定义属性--val的形式存储,我们希望以mm:ss格式显示它,例如。

我们使用--val60(一分钟中的秒数)的比率的向下取整,来获取分钟数,并使用模运算获取该分钟数后的秒数。然后,我们使用巧妙的counter技巧在伪元素中显示格式化后的时间。

@property --min {
  syntax: '<integer>';
  initial-value: 0;
  inherits: false;
}

code {
  --min: calc(var(--val)/60 - .5);
  --sec: calc(var(--val) - var(--min)*60);
  counter-reset: min var(--min) sec var(--sec);
  
  &::after {
    /* so we get the time formatted as 02:09 */
    content: 
      counter(min, decimal-leading-zero) ':' 
      counter(sec, decimal-leading-zero);
  }
}

这在大多数情况下都有效,但在--val恰好为0时会遇到问题。在这种情况下,0/600,然后减去.5,我们得到-.5,将其四舍五入为绝对值中相邻的较大整数。也就是说,-1,而不是0!这意味着我们的结果将最终为-01:60,而不是00:00

幸运的是,我们有一个简单的修复方法,那就是稍微改变获取分钟数--min的公式

--min: max(0, var(--val)/60 - .5);

还有其他格式选项,如下所示

/* shows time formatted as 2:09 */
content: counter(min) ':' counter(sec, decimal-leading-zero);

/* shows time formatted as 2m9s */
content: counter(min) 'm' counter(sec) 's';

我们还可以应用相同的技术将时间格式化为hh:mm:ss (实时测试)。

@property --hrs {
  syntax: '<integer>';
  initial-value: 0;
  inherits: false;
}

@property --min {
  syntax: '<integer>';
  initial-value: 0;
  inherits: false;
}

code {
  --hrs: max(0, var(--val)/3600 - .5);
  --mod: calc(var(--val) - var(--hrs)*3600);
  --min: max(0, var(--mod)/60 - .5);
  --sec: calc(var(--mod) - var(--min)*60);
  counter-reset: hrs var(--hrs) var(--min) sec var(--sec);
  
  &::after {
    /* so we get the time formatted as 00:02:09 */
    content: 
      counter(hrs, decimal-leading-zero) ':' 
      counter(min, decimal-leading-zero) ':' 
      counter(sec, decimal-leading-zero);
  }
}

这是我用来设置原生范围滑块(例如以下滑块)的output样式的一种技术。

Screenshot showing a styled slider with a tooltip above the thumb indicating the elapsed time formatted as mm:ss. On the right of the slider, there's the remaining time formatted as -mm:ss.
表示时间的样式化范围输入 (实时演示)

时间不是我们唯一可以使用的。计数器值必须是整数,这意味着模运算技巧也方便用于显示小数,如以下第二个滑块所示。

Screenshot showing three styled sliders withe second one having a tooltip above the thumb indicating the decimal value.
样式化范围输入,其中一个具有小数输出 (实时演示)

再举几个这样的例子

Screenshot showing multiple styled sliders with the third one being focused and showing a tooltip above the thumb indicating the decimal value.
样式化范围输入,其中一个具有小数输出 (实时演示)
Screenshot showing two styled sliders with the second one being focused and showing a tooltip above the thumb indicating the decimal value.
样式化范围输入,其中一个具有小数输出 (实时演示)

更多用例

假设我们有一个音量滑块,两端都有一个图标。根据我们移动滑块滑块的方向,两个图标之一会高亮显示。这可以通过获取每个图标的符号--sgn-ico(滑块之前为-1,滑块之后为1)与滑块当前值--val与其之前值--prv之间的差值的符号--sgn-dir之间的差值的绝对值--abs来实现。如果此值为0,则表示我们正在向当前图标方向移动,因此我们将它的opacity设置为1。否则,我们正在远离当前图标,因此我们将它的opacity保持在.15

这意味着,每当范围输入的值发生变化时,我们不仅需要更新它在父级上的当前值--val,还需要更新它在相同父级包装器上的之前值,另一个自定义属性--prv

addEventListener('input', e => {
  let _t = e.target, _p = _t.parentNode;
	
  _p.style.setProperty('--prv', +_p.style.getPropertyValue('--val'))
  _p.style.setProperty('--val', +_t.value)
})

它们的差值的符号就是我们正在前进的方向的符号--sgn-dir,如果它的符号--sgn-ico与我们正在前进的方向的符号--sgn-dir一致,则当前图标将高亮显示。也就是说,如果它们的差值的绝对值--abs0,并且同时父级包装器被选中(它正在被悬停或它里面的范围input处于焦点)。

[role='group'] {
  --dir: calc(var(--val) - var(--prv));
  --sgn-dir: clamp(-1, var(--dir), 1);
  --sel: 0; /* is the slider focused or hovered? Yes 1/ No 0 */
  
  &:hover, &:focus-within { --sel: 1; }
}

.ico {
  --abs: max(var(--sgn-dir) - var(--sgn-ico), var(--sgn-ico) - var(--sgn-dir));
  --hlg: calc(var(--sel)*(1 - min(1, var(--abs)))); /* highlight current icon? Yes 1/ No 0 */
  opacity: calc(1 - .85*(1 - var(--hlg)));
}

另一个用例是让网格上项目的属性值取决于水平--abs-i和垂直--abs-j距离中间--m的总和的奇偶性。例如,假设我们对background-color执行此操作

@property --floor {
  syntax: '<integer>';
  initial-value: 0;
  inherits: false;
}

.cell {
  --m: calc(.5*(var(--n) - 1));
  --abs-i: max(var(--m) - var(--i), var(--i) - var(--m));
  --abs-j: max(var(--m) - var(--j), var(--j) - var(--m));
  --sum: calc(var(--abs-i) + var(--abs-j));
  --floor: max(0, var(--sum)/2 - .5);
  --mod: calc(var(--sum) - var(--floor)*2);
  background: hsl(calc(90 + var(--mod)*180), 50%, 65%);
}
Screenshot showing a 16x16 grid where each tile is either lime or purple.
背景取决于水平和垂直距离到中间的总和的奇偶性 (实时演示)

我们可以通过使用除以 2 的向下取整的总和的模 2 来增加趣味性

@property --floor {
  syntax: '<integer>';
  initial-value: 0;
  inherits: false;
}

@property --int {
  syntax: '<integer>';
  initial-value: 0;
  inherits: false;
}

.cell {
  --m: calc(.5*(var(--n) - 1));
  --abs-i: max(var(--m) - var(--i), var(--i) - var(--m));
  --abs-j: max(var(--m) - var(--j), var(--j) - var(--m));
  --sum: calc(var(--abs-i) + var(--abs-j));
  --floor: max(0, var(--sum)/2 - .5);
  --int: max(0, var(--floor)/2 - .5);
  --mod: calc(var(--floor) - var(--int)*2);
  background: hsl(calc(90 + var(--mod)*180), 50%, 65%);
}
Screenshot showing a 16x16 grid where each tile is either lime or purple.
之前演示的更有趣变体 (实时演示)

我们还可以使旋转方向和conic-gradient()的方向都依赖于水平距离--abs-i和垂直距离--abs-j与中间--m的总和--sum的奇偶性。当总和--sum为偶数时,通过水平翻转元素来实现这一点。在下面的示例中,旋转和大小也通过 Houdini 进行动画处理(它们都依赖于一个自定义属性--f,我们注册该属性并将其从0动画到1),蠕虫色调--hueconic-gradient()蒙版也是如此,这两个动画的延迟计算方式与之前的示例完全相同。

@property --floor {
  syntax: '<integer>';
  initial-value: 0;
  inherits: false;
}

.🐛 {
  --m: calc(.5*(var(--n) - 1));
  --abs-i: max(var(--m) - var(--i), var(--i) - var(--m));
  --abs-j: max(var(--m) - var(--j), var(--j) - var(--m));
  --sum: calc(var(--abs-i) + var(--abs-j));
  --floor: calc(var(--sum)/2 - .5);
  --mod: calc(var(--sum) - var(--floor)*2);
  --sgn: calc(2*var(--mod) - 1); /* -1 if --mod is 0; 1 id --mod is 1 */
  transform: 
    scalex(var(--sgn)) 
    scale(var(--f)) 
    rotate(calc(var(--f)*180deg));
  --hue: calc(var(--sgn)*var(--f)*360);
}
网格波:三角形彩虹蠕虫(实时演示)。

最后,到目前为止解释的技术的另一个重要用例是,不仅对,而且对凹动画的 3D 形状进行着色,而无需使用任何 JavaScript!这是一个非常庞大的主题,解释所有内容需要一篇文章才能像这篇一样长,所以我在这里根本不会详细介绍。但我制作了一些视频,其中我从头开始编写了一些基本的纯 CSS 3D 形状(包括一个木星和一个形状不同的金属星),你当然也可以在 CodePen 上查看以下示例的 CSS 代码。

音乐玩具(实时演示