现在已经有一段时间了,CSS 规范中包含了许多非常有用的 数学函数,例如三角函数(sin()
、cos()
、tan()
、asin()
、acos()
、atan()
、atan2()
)、指数函数(pow()
、exp()
、sqrt()
、log()
、hypot()
)、符号相关函数(abs()
、sign()
)和步进值函数(round()
、mod()
、rem()
)。
但是,这些还没有在任何浏览器中实现,因此本文将展示如何使用我们已经拥有的 CSS 功能来计算 abs()
、sign()
、round()
和 mod()
应该返回的值。然后我们将看看这让我们今天可以构建哪些酷炫的东西。

请注意,这些技术从未打算在恐龙漫游互联网的那些日子里的浏览器中使用。其中一些甚至依赖于浏览器支持注册自定义属性(使用 @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()
内除以带单位的数字。
此外,如果 --a
为 0
,则此解决方案仅在我们将 --sign
(这仅在 Chromium 浏览器中受支持)注册为 initial-value
为 0
时有效。
@property --sign {
syntax: '<integer>';
initial-value: 0;
inherits: false /* or true depending on context */
}
这是因为 --a
为 0
,也使 --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
。最后,如果 --a
为 0
,则它在上下限之间,因此该函数返回其值(在本例中为 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-delay
和 transition-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; }

现在我们添加两组关键帧来动画化一个缩放的 `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; }
}

现在是有趣的部分!我们计算第一个项目索引和最后一个项目索引之间的中间值。这是两个的 算术 平均值(因为我们的索引是基于零的,第一个和最后一个分别是 `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` 值相对于中间值的对称性。

为了直观比较这两个选项,您可以重新运行以下演示,看看一开始会发生什么。
更高级的例子如下
这里,每一个 `--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});
这将给我们以下结果

现在让我们看看一些更有趣的例子。我们不会详细介绍它们背后的“如何”,因为对称值技术的工作原理与前面的例子完全相同,而其他内容超出了本文的范围。但是,每个示例的标题中都有一个指向 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;
}

从 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
(标签是选中选项的情况),则--sgn
为0
,并且background-position
计算为50%*(1 + 0) = 50%*1 = 50%
,因此我们只看到中间的垂直条纹(蓝色条纹)。
如果--i
大于--k
(标签是选中项之后选项的情况),则--sgn
为1
,并且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
格式显示它,例如。
我们使用--val
与60
(一分钟中的秒数)的比率的向下取整,来获取分钟数,并使用模运算获取该分钟数后的秒数。然后,我们使用巧妙的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/60
为0
,然后减去.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
样式的一种技术。

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

再举几个这样的例子


更多用例
假设我们有一个音量滑块,两端都有一个图标。根据我们移动滑块滑块的方向,两个图标之一会高亮显示。这可以通过获取每个图标的符号--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
一致,则当前图标将高亮显示。也就是说,如果它们的差值的绝对值--abs
为0
,并且同时父级包装器被选中(它正在被悬停或它里面的范围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%);
}

我们可以通过使用除以 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%);
}

我们还可以使旋转方向和conic-gradient()
的方向都依赖于水平距离--abs-i
和垂直距离--abs-j
与中间--m
的总和--sum
的奇偶性。当总和--sum
为偶数时,通过水平翻转元素来实现这一点。在下面的示例中,旋转和大小也通过 Houdini 进行动画处理(它们都依赖于一个自定义属性--f
,我们注册该属性并将其从0
动画到1
),蠕虫色调--hue
和conic-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 代码。
哇,这太棒了,这就是我们应该在学校教数学的方式 :)
谢谢,我真的很喜欢这种东西。
相关地,有没有人知道为什么 CSS 错误地将幂函数称为指数函数?f(x)=exp(x) 很好,但其他函数,如 sqrt(x) 等,根本不是指数函数。
我对这种偏离数学定义的原因感到好奇。
哪些浏览器可以使用 round 函数?
https://caniuse.cn/mdn-css_at-rules_property
MDN 似乎甚至不知道 @property 是什么
现在看起来不太好。也许等待真正的 CSS round 函数更快更安全?
然后,我是否需要为每个要分配舍入值的变量定义这样的 @property?“--round” 这个名字表明这是一种可重用的函数。我对 CSS 变量还没有完全理解。
我的库定义了字体大小变量(并允许用户覆盖),我希望行高舍入到整数像素,以避免插值错误和真实屏幕上的不均匀行高。现在,我必须显式设置这些行高。
我似乎无法让舍入工作。据我所知,将变量注册为整数应该会强制将带有小数的值转换为整数。我不明白的是什么?
声明属性时去掉前导连字符。
@property round {
我不知道,但我把它改成了
将
--round
向上舍入到 401