Braydon Coyer 最近发起了一个每月举办的 CSS 艺术挑战。他实际上曾联系我,希望我捐赠一本我的书 Move Things with CSS 作为挑战获胜者的奖品——我很乐意这样做!
第一个月的挑战是什么?**弹簧**。在思考为挑战制作什么时,弹簧立即浮现在我的脑海。你认识弹簧吧?那种经典的玩具,你把它从楼梯上推下去,它就会依靠自身的动量向下运动。

我们能用 CSS 创建一个像那样沿着楼梯向下移动的弹簧吗?这正是我喜欢的挑战类型,所以我认为我们可以在本文中一起解决这个问题。准备好了吗?(双关语)
设置弹簧的 HTML
让我们让它更灵活。(没有双关语。)我的意思是,我们希望能够通过 CSS 自定义属性来控制弹簧的行为,这使我们能够在需要时灵活地交换值。
以下是我设置场景的方式,为了简洁起见,使用 Pug 编写
- const RING_COUNT = 10;
.container
.scene
.plane(style=`--ring-count: ${RING_COUNT}`)
- let rings = 0;
while rings < RING_COUNT
.ring(style=`--index: ${rings};`)
- rings++;
这些内联自定义属性是我们更新环数的一种简单方法,在我们深入研究此挑战时会派上用场。上面的代码为我们提供了 10 个环,编译后的 HTML 看起来 类似这样
<div class="container">
<div class="scene">
<div class="plane" style="--ring-count: 10">
<div class="ring" style="--index: 0;"></div>
<div class="ring" style="--index: 1;"></div>
<div class="ring" style="--index: 2;"></div>
<div class="ring" style="--index: 3;"></div>
<div class="ring" style="--index: 4;"></div>
<div class="ring" style="--index: 5;"></div>
<div class="ring" style="--index: 6;"></div>
<div class="ring" style="--index: 7;"></div>
<div class="ring" style="--index: 8;"></div>
<div class="ring" style="--index: 9;"></div>
</div>
</div>
</div>
初始弹簧 CSS
我们需要一些样式!我们想要的是一个 三维场景。我考虑了一些我们以后可能想要做的事情,所以这就是拥有一个带有 .scene
类的额外包装组件背后的想法。
让我们首先为我们的“无限弹簧”场景定义一些属性
:root {
--border-width: 1.2vmin;
--depth: 20vmin;
--stack-height: 6vmin;
--scene-size: 20vmin;
--ring-size: calc(var(--scene-size) * 0.6);
--plane: radial-gradient(rgb(0 0 0 / 0.1) 50%, transparent 65%);
--ring-shadow: rgb(0 0 0 / 0.5);
--hue-one: 320;
--hue-two: 210;
--blur: 10px;
--speed: 1.2s;
--bg: #fafafa;
--ring-filter: brightness(1) drop-shadow(0 0 0 var(--accent));
}
这些属性定义了弹簧和场景的特征。在大多数 3D CSS 场景中,我们将全局设置 transform-style
* {
box-sizing: border-box;
transform-style: preserve-3d;
}
现在我们需要 .scene
的样式。诀窍是转换 .plane
,使其看起来像我们的 CSS 弹簧在无限地向下移动楼梯。我不得不进行一些调整才能完全按照我想要的方式获得效果,所以请暂时容忍这些幻数,因为它们稍后会变得有意义。
.container {
/* Define the scene's dimensions */
height: var(--scene-size);
width: var(--scene-size);
/* Add depth to the scene */
transform:
translate3d(0, 0, 100vmin)
rotateX(-24deg) rotateY(32deg)
rotateX(90deg)
translateZ(calc((var(--depth) + var(--stack-height)) * -1))
rotate(0deg);
}
.scene,
.plane {
/* Ensure our container take up the full .container */
height: 100%;
width: 100%;
position: relative;
}
.scene {
/* Color is arbitrary */
background: rgb(162 25 230 / 0.25);
}
.plane {
/* Color is arbitrary */
background: rgb(25 161 230 / 0.25);
/* Overrides the previous selector */
transform: translateZ(var(--depth));
}
这里 .container
的转换发生了一些事情。具体来说
translate3d(0, 0, 100vmin)
: 这将.container
向前移动,并阻止我们的 3D 工作被主体裁剪。我们没有在此级别使用perspective
,因此我们可以这样做。rotateX(-24deg) rotateY(32deg)
: 这根据我们的偏好旋转场景。rotateX(90deg)
: 这将.container
旋转四分之一圈,这默认情况下会使.scene
和.plane
变平。否则,这两层将看起来像 3D 立方体的顶部和底部。translate3d(0, 0, calc((var(--depth) + var(--stack-height)) * -1))
: 我们可以使用它来移动场景并在 y 轴(实际上是 z 轴)上居中。这取决于设计师的喜好。在这里,我们使用--depth
和--stack-height
来居中元素。rotate(0deg)
: 虽然目前未使用,但我们可能希望稍后旋转场景或动画化场景的旋转。
要可视化 .container
中发生的情况,请查看此演示,然后点击任意位置以查看应用的 transform
(抱歉,仅限 Chromium。😭)
现在我们有了带样式的场景!💪
设置弹簧环的样式
这就是 CSS 自定义属性发挥作用的地方。我们从 HTML 中获取了内联属性 --index
和 --ring-count
。我们还在 CSS 中预定义了之前在 :root
上看到的属性。
内联属性将在定位每个环中发挥作用
.ring {
--origin-z:
calc(
var(--stack-height) - (var(--stack-height) / var(--ring-count))
* var(--index)
);
--hue: var(--hue-one);
--accent: hsl(var(--hue) 100% 55%);
height: var(--ring-size);
width: var(--ring-size);
border-radius: 50%;
border: var(--border-width) solid var(--accent);
position: absolute;
top: 50%;
left: 50%;
transform-origin: calc(100% + (var(--scene-size) * 0.2)) 50%;
transform:
translate3d(-50%, -50%, var(--origin-z))
translateZ(0)
rotateY(0deg);
}
.ring:nth-of-type(odd) {
--hue: var(--hue-two);
}
请注意我们是如何计算 --origin-z
值以及如何使用 transform
属性定位每个环的。这在使用 position: absolute
定位每个环之后进行。
还值得注意的是,我们在最后一个规则集中如何交替更改每个环的颜色。当我第一次实现这一点时,我想创建一个彩虹弹簧,使环依次穿过各种色调。但这会给效果增加一些复杂性。
现在我们在凸起的 .plane
上有一些环了
变换弹簧环
是时候让它们动起来了!你可能已经注意到,我们在每个 .ring
上设置了一个 transform-origin
,如下所示
.ring {
transform-origin: calc(100% + (var(--scene-size) * 0.2)) 50%;
}
这是基于 .scene
的大小。该 0.2
值是 .ring
定位后 .scene
剩余可用大小的一半。
我们当然可以稍微整理一下!
:root {
--ring-percentage: 0.6;
--ring-size: calc(var(--scene-size) * var(--ring-percentage));
--ring-transform:
calc(
100%
+ (var(--scene-size) * ((1 - var(--ring-percentage)) * 0.5))
) 50%;
}
.ring {
transform-origin: var(--ring-transform);
}
为什么是那个 transform-origin
?好吧,我们需要让环看起来像是在偏离中心移动。玩弄单个环的 transform
是确定我们想要应用的 transform
的一个好方法。在该演示中移动滑块以查看环翻转
添加所有环,我们就可以翻转整个堆栈!
嗯,但它们并没有落到下一个台阶上。我们怎样才能让每个环都落到正确的位置呢?
好吧,我们有一个计算出的 --origin-z
,所以让我们计算 --destination-z
,以便随着环的 transform
深度发生变化。如果我们有一个位于堆栈顶部的环,那么它在落下后应该位于底部。我们可以使用自定义属性为每个环限定一个目标位置
ring {
--destination-z: calc(
(
(var(--depth) + var(--origin-z))
- (var(--stack-height) - var(--origin-z))
) * -1
);
transform-origin: var(--ring-transform);
transform:
translate3d(-50%, -50%, var(--origin-z))
translateZ(calc(var(--destination-z) * var(--flipped, 0)))
rotateY(calc(var(--flipped, 0) * 180deg));
}
现在尝试移动堆栈!我们快成功了。🙌
动画化环
我们希望我们的环翻转然后落下。第一次尝试可能看起来像这样
.ring {
animation-name: slink;
animation-duration: 2s;
animation-fill-mode: both;
animation-iteration-count: infinite;
}
@keyframes slink {
0%, 5% {
transform:
translate3d(-50%, -50%, var(--origin-z))
translateZ(0)
rotateY(0deg);
}
25% {
transform:
translate3d(-50%, -50%, var(--origin-z))
translateZ(0)
rotateY(180deg);
}
45%, 100% {
transform:
translate3d(-50%, -50%, var(--origin-z))
translateZ(var(--destination-z))
rotateY(180deg);
}
}
哎呀,这完全不对!
但这仅仅是因为我们没有使用 animation-delay
。所有环都在同时,嗯,摆动。让我们根据环的 --index
引入一个 animation-delay
,以便它们依次摆动。
.ring {
animation-delay: calc(var(--index) * 0.1s);
}
好的,这确实“更好”了。但时间安排仍然不对。不过,更突出的问题是 animation-delay
的不足之处。它仅应用于第一次动画迭代。之后,我们失去了效果。
在这一点上,让我们给环上色,使它们依次穿过色轮。这将使我们更容易观察正在发生的事情。
.ring {
--hue: calc((360 / var(--ring-count)) * var(--index));
}
这就好了!✨
回到问题本身。由于我们无法指定应用于每次迭代的延迟,因此也无法获得我们想要的效果。对于我们的弹簧,如果我们能够拥有一个一致的 animation-delay
,我们也许能够实现我们想要的效果。并且我们可以在依赖于我们的作用域自定义属性的同时使用一个关键帧。甚至 animation-repeat-delay
也可以是一个有趣的补充。
此功能在 JavaScript 动画解决方案中可用。例如,GreenSock 允许您指定 delay
和 repeatDelay
。
但是,我们的弹簧示例并不是说明此问题的最简单方法。让我们将其分解成一个基本示例。考虑两个盒子。并且您希望它们交替旋转。
我们如何使用 CSS 并且不使用任何“技巧”来做到这一点?一个想法是向其中一个盒子添加延迟
.box {
animation: spin 1s var(--delay, 0s) infinite;
}
.box:nth-of-type(2) {
--delay: 1s;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
但是,这不起作用,因为红色盒子将继续旋转。同样,蓝色盒子在初始 animation-delay
之后也会继续旋转。
但是,使用类似 GreenSock 的工具,我们可以相对轻松地实现我们想要的效果
import gsap from 'https://cdn.skypack.dev/gsap'
gsap.to('.box', {
rotate: 360,
/**
* A function based value, means that the first box has a delay of 0 and
* the second has a delay of 1
*/
delay: (index) > index,
repeatDelay: 1,
repeat: -1,
ease: 'power1.inOut',
})
就是这样!
但是,如何在没有 JavaScript 的情况下做到这一点呢?
好吧,我们必须“hack”我们的 @keyframes
并完全放弃 animation-delay
。相反,我们将用空隙填充 @keyframes
。这会带来各种怪癖,但让我们继续构建一个新的关键帧。这将使元素完全旋转两次
@keyframes spin {
50%, 100% {
transform: rotate(360deg);
}
}
就像我们将关键帧切成两半。现在,我们将不得不使 animation-duration
加倍以获得相同的速度。不使用 animation-delay
,我们可以尝试在第二个框上设置 animation-direction: reverse
.box {
animation: spin 2s infinite;
}
.box:nth-of-type(2) {
animation-direction: reverse;
}
差不多了。
旋转方向错了。我们可以使用一个包装元素并旋转它,但这可能会变得很棘手,因为有更多的事情需要平衡。另一种方法是创建两个关键帧而不是一个
@keyframes box-one {
50%, 100% {
transform: rotate(360deg);
}
}
@keyframes box-two {
0%, 50% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
就是这样
如果我们有一种方法可以使用类似这样的东西来指定重复延迟,那就会容易很多。
/* Hypothetical! */
animation: spin 1s 0s 1s infinite;
或者如果重复延迟与初始延迟匹配,我们可能会为此创建一个组合器。
/* Hypothetical! */
animation: spin 1s 1s+ infinite;
这肯定是一个有趣的补充!
所以,我们需要为所有这些环创建关键帧吗?
是的,如果我们想要一致的延迟。我们需要根据我们将用作动画窗口的内容来执行此操作。在关键帧重复之前,所有环都需要“滑动”并稳定下来。
手动写出这些将非常糟糕。但这就是我们拥有 CSS 预处理器的原因,对吧?好吧,至少在我们获得循环和网络上的一些额外自定义属性功能之前。😉
今天选择的武器将是 Stylus。它是我最喜欢的 CSS 预处理器,并且已经有一段时间了。习惯意味着我还没有迁移到 Sass。此外,我喜欢 Stylus 缺乏所需的语法和灵活性。
幸好我们只需要编写一次。
// STYLUS GENERATED KEYFRAMES BE HERE...
$ring-count = 10
$animation-window = 50
$animation-step = $animation-window / $ring-count
for $ring in (0..$ring-count)
// Generate a set of keyframes based on the ring index
// index is the ring
$start = $animation-step * ($ring + 1)
@keyframes slink-{$ring} {
// In here is where we need to generate the keyframe steps based on ring count and window.
0%, {$start * 1%} {
transform
translate3d(-50%, -50%, var(--origin-z))
translateZ(0)
rotateY(0deg)
}
// Flip without falling
{($start + ($animation-window * 0.75)) * 1%} {
transform
translate3d(-50%, -50%, var(--origin-z))
translateZ(0)
rotateY(180deg)
}
// Fall until the cut-off point
{($start + $animation-window) * 1%}, 100% {
transform
translate3d(-50%, -50%, var(--origin-z))
translateZ(var(--destination-z))
rotateY(180deg)
}
}
以下是这些变量的含义
$ring-count
:我们弹簧玩具中的环数。$animation-window
:这是我们可以滑动进入的关键帧的百分比。在我们的示例中,我们说我们希望在关键帧的50%
处滑动。剩余的50%
应该用于延迟。$animation-step
:这是每个环的计算出的交错。我们可以用它来计算每个环的唯一关键帧百分比。
以下是它编译成 CSS 的方式,至少对于前几次迭代是这样的
查看完整代码
@keyframes slink-0 {
0%, 4.5% {
transform:
translate3d(-50%, -50%, var(--origin-z))
translateZ(0)
rotateY(0deg);
}
38.25% {
transform:
translate3d(-50%, -50%, var(--origin-z))
translateZ(0)
rotateY(180deg);
}
49.5%, 100% {
transform:
translate3d(-50%, -50%, var(--origin-z))
translateZ(var(--destination-z))
rotateY(180deg);
}
}
@keyframes slink-1 {
0%, 9% {
transform:
translate3d(-50%, -50%, var(--origin-z))
translateZ(0)
rotateY(0deg);
}
42.75% {
transform:
translate3d(-50%, -50%, var(--origin-z))
translateZ(0)
rotateY(180deg);
}
54%, 100% {
transform:
translate3d(-50%, -50%, var(--origin-z))
translateZ(var(--destination-z))
rotateY(180deg);
}
}
最后要做的是将每组关键帧应用到每个环上。如果我们希望通过更新它来定义--index
和--name
,我们可以使用我们的标记来实现。
- const RING_COUNT = 10;
.container
.scene
.plane(style=`--ring-count: ${RING_COUNT}`)
- let rings = 0;
while rings < RING_COUNT
.ring(style=`--index: ${rings}; --name: slink-${rings};`)
- rings++;
编译后得到以下结果
<div class="container">
<div class="scene">
<div class="plane" style="--ring-count: 10">
<div class="ring" style="--index: 0; --name: slink-0;"></div>
<div class="ring" style="--index: 1; --name: slink-1;"></div>
<div class="ring" style="--index: 2; --name: slink-2;"></div>
<div class="ring" style="--index: 3; --name: slink-3;"></div>
<div class="ring" style="--index: 4; --name: slink-4;"></div>
<div class="ring" style="--index: 5; --name: slink-5;"></div>
<div class="ring" style="--index: 6; --name: slink-6;"></div>
<div class="ring" style="--index: 7; --name: slink-7;"></div>
<div class="ring" style="--index: 8; --name: slink-8;"></div>
<div class="ring" style="--index: 9; --name: slink-9;"></div>
</div>
</div>
</div>
然后可以相应地更新我们的样式
.ring {
animation: var(--name) var(--speed) both infinite cubic-bezier(0.25, 0, 1, 1);
}
时机就是一切。因此,我们放弃了默认的animation-timing-function
,并使用cubic-bezier
。我们还利用了我们在开始时定义的--speed
自定义属性。
哇哦。现在我们有一个弹簧玩具的 CSS 动画了!玩弄代码中的一些变量,看看你可以产生什么不同的行为。
创建无限动画
现在我们已经完成了最困难的部分,我们可以让这个动画无限重复。为此,我们将随着弹簧玩具的滑动转换场景,使其看起来像它正在滑回其原始位置。
.scene {
animation: step-up var(--speed) infinite linear both;
}
@keyframes step-up {
to {
transform: translate3d(-100%, 0, var(--depth));
}
}
哇,这花费了很少的精力!
我们可以从.scene
和.plane
中移除平台颜色,以防止动画过于突兀。
快完成了!最后要解决的问题是环堆栈在再次滑动之前会翻转。这就是我们之前提到的颜色使用起来很方便的地方。将环数更改为奇数,例如11
,然后切换回交替环颜色。
成功!我们有一个工作的 CSS 弹簧玩具!它也是可配置的!
有趣的变化
怎么样一个“翻转”效果?我的意思是让弹簧玩具以交替的方式滑动。如果我们在场景中添加一个额外的包装元素,我们可以在每次滑动时将场景旋转180deg
。
- const RING_COUNT = 11;
.container
.flipper
.scene
.plane(style=`--ring-count: ${RING_COUNT}`)
- let rings = 0;
while rings < RING_COUNT
.ring(style=`--index: ${rings}; --name: slink-${rings};`)
- rings++;
就动画而言,我们可以使用steps()
计时函数,并使用两倍的--speed
。
.flipper {
animation: flip-flop calc(var(--speed) * 2) infinite steps(1);
height: 100%;
width: 100%;
}
@keyframes flip-flop {
0% {
transform: rotate(0deg);
}
50% {
transform: rotate(180deg);
}
100% {
transform: rotate(360deg);
}
}
最后但并非最不重要的一点是,让我们更改.scene
元素的step-up
动画的工作方式。它不再需要在 x 轴上移动。
@keyframes step-up {
0% {
transform: translate3d(-50%, 0, 0);
}
100% {
transform: translate3d(-50%, 0, var(--depth));
}
}
请注意我们使用的animation-timing-function
。steps(1)
的使用使它成为可能。
如果你想要另一个有趣的steps()
用法,请查看这个#SpeedyCSSTip!
为了增加一点趣味,我们可以缓慢旋转整个场景。
.container {
animation: rotate calc(var(--speed) * 40) infinite linear;
}
@keyframes rotate {
to {
transform:
translate3d(0, 0, 100vmin)
rotateX(-24deg)
rotateY(-32deg)
rotateX(90deg)
translateZ(calc((var(--depth) + var(--stack-height)) * -1))
rotate(360deg);
}
}
我喜欢它!当然,样式是主观的……所以,我做了一个小应用程序,你可以用它来配置你的弹簧玩具。
这是我用阴影和主题进一步完善的“原始”和“翻转”版本。
最终演示
就是这样!
这至少是一种制作纯 CSS 弹簧玩具的方法,它既是 3D 的又是可配置的。当然,你可能不会每天都用到这样的东西,但它引出了有趣的 CSS 动画技巧。它也引发了一个问题,即在 CSS 中是否拥有一个animation-repeat-delay
属性会有用。你怎么看?你认为它会有一些好的用例吗?我很想知道。
一定要玩弄一下代码——所有代码都可以在这个 CodePen 集合中找到!
我认为这可以在不为每个动画元素创建不同的关键帧的情况下实现。您可以对所有元素使用一组关键帧,然后使用负
animation-delay
属性在动画的不同点开始每个元素。在您使用两个旋转框创建的示例中,您可以将以下关键帧应用于两者,持续时间为 2 秒
然后将
animation-delay: -1s
应用于第二个框。这些框将交替旋转。是的!你完全可以用这种方式做到。那个“基本”的例子仅仅是那个。这是一种“基本”的方式来尝试展示问题。当事情变得更复杂时,这种方法在目前的 CSS 中很难扩展。事实上,其中一个演示展示了如何对一次性滑动使用相同关键帧和不同的延迟。但是,当我们尝试添加多个环并无限地保持所有环的一致延迟时间时。它变得更难以扩展。
这更多的是关于探索 CSS 的功能以及我们如何实现某些功能或希望在语言中引入哪些功能作为特性。我们始终乐于倾听社区关于他们希望在 CSS 中看到什么的意见等。
所有 3D 内容都有些超出我的理解范围,但对于交替旋转框的重复延迟,似乎可以使用单个
@keyframes
来实现。我的意思是,您可以将animation-delay
与@keyframes
的“停止”部分结合起来。这是一个用于现场演示的分支
https://codepen.io/AnisMan/pen/NWyRJVd
是的!你完全可以用这种方式做到。那个“基本”的例子仅仅是那个。这是一种“基本”的方式来尝试展示问题。当事情变得更复杂时,这种方法在目前的 CSS 中很难扩展。
这更多的是关于探索 CSS 的功能以及我们如何实现某些功能或希望在语言中引入哪些功能作为特性。
该死!这是一篇非常有趣且详尽的文章。感谢您抽出时间。很高兴看到您仅使用 css 就能完成什么。