用 CSS 创建 3D 弹簧?挑战接受!

Avatar of Jhey Tompkins
Jhey Tompkins

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200 美元的免费额度!

Braydon Coyer 最近发起了一个每月举办的 CSS 艺术挑战。他实际上曾联系我,希望我捐赠一本我的书 Move Things with CSS 作为挑战获胜者的奖品——我很乐意这样做!

第一个月的挑战是什么?**弹簧**。在思考为挑战制作什么时,弹簧立即浮现在我的脑海。你认识弹簧吧?那种经典的玩具,你把它从楼梯上推下去,它就会依靠自身的动量向下运动。

Animated Gif of a Slinky toy going down stairs.
一个摆动的弹簧

我们能用 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 允许您指定 delayrepeatDelay

但是,我们的弹簧示例并不是说明此问题的最简单方法。让我们将其分解成一个基本示例。考虑两个盒子。并且您希望它们交替旋转。

我们如何使用 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-functionsteps(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 集合中找到!