制作三维的CSS Slinky的教程

97 阅读13分钟

Braydon Coyer最近发起了每月一次的CSS艺术挑战。实际上,他曾与我联系过,希望我捐出一本我的书 用CSS移动东西作为挑战赛获胜者的奖品--我非常乐意这样做!"。

第一个月的挑战是什么?春天。当想到要为这个挑战做什么时,我马上想到了Slinkys。你知道Slinkys,对吗?那种经典的玩具,你把它从楼梯上敲下来,它就会以自己的动力行驶。

Animated Gif of a Slinky toy going down stairs.

滑动的Slinky

我们能在CSS中创建一个像这样走下楼梯的Slinky吗?这正是我喜欢的挑战,所以我想我们可以在这篇文章中一起解决这个问题。准备好开始了吗?(双关语)。

设置Slinky的HTML

让我们把它变得灵活。(我的意思是,我们希望能够通过CSS的自定义属性来控制Slinky的行为,使我们能够在需要的时候灵活地交换数值。

下面是我如何设置场景的,为了简洁起见,用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>

最初的Slinky CSS

我们将需要一些样式!我们想要的是一个三维的场景。我注意到我们以后可能要做的一些事情,所以这就是有一个额外的包装组件与.scene 类背后的想法。

让我们先为我们的 "无限的-slinky "场景定义一些属性:

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

这些属性定义了我们的Slinky和场景的特性。对于大多数的3D CSS场景,我们要全面地设置transform-style

* {
  box-sizing: border-box;
  transform-style: preserve-3d;
}

现在我们需要为我们的.scene 的样式。诀窍在于翻译.plane ,这样看起来就像我们的CSS Slinky在无限地沿着楼梯移动。我不得不四处游玩,以使事情完全符合我的要求,所以现在先忍受一下这个神奇的数字,因为它们以后会有意义。

.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 ,阻止我们的三维工作被身体切断。我们在这个层面上没有使用perspective ,所以我们可以不使用它。
  • rotateX(-24deg) rotateY(32deg): 这将根据我们的偏好来旋转场景。
  • rotateX(90deg): 这将使.container 旋转四分之一圈,默认情况下,这将使.scene.plane 变平,否则,这两层看起来就像一个三维立方体的顶部和底部。
  • translate3d(0, 0, calc((var(--depth) + var(--stack-height)) * -1)): 我们可以用它来移动场景,并把它放在Y轴(好吧,实际上是Z轴)的中心。这是在设计者的眼中。在这里,我们正在使用--depth--stack-height 来居中。
  • rotate(0deg): 虽然,目前没有使用,但我们可能想在以后旋转场景或对场景的旋转制作动画。

为Slinky的圆环设计样式

这就是那些CSS自定义属性要发挥其作用的地方。我们有内嵌的属性--index--ring-count 来自我们的HTML。我们在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

变换Slinky的环

现在是时候让东西动起来了!你可能已经注意到,我们在每个.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));
}

这样就好了!✨

回到问题上来。因为我们无法指定一个应用于每个迭代的延迟,我们也无法得到我们想要的效果。对于我们的Slinky,如果我们能够有一个一致的animation-delay ,我们也许能够实现我们想要的效果。而且我们可以使用一个关键帧,同时依靠我们的范围内的自定义属性。甚至一个animation-repeat-delay ,也可能是一个有趣的补充。

这种功能在JavaScript动画解决方案中也有。例如,GreenSock允许你指定一个delay 和一个repeatDelay

但是,我们的Slinky例子并不是说明这个问题的最简单的东西。让我们把它分解成一个基本的例子。考虑两个盒子。而你想让它们交替旋转。

我们如何用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,我们怎么能做到这一点呢?

好吧,我们必须 "入侵 "我们的@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 Slinky!玩玩代码中的一些变量,看看你能产生什么不同的行为。

创建一个无限的动画

现在我们已经解决了最困难的部分,我们可以让这个动画无限地重复。要做到这一点,我们要在我们的Slinky滑行时对场景进行翻译,这样它看起来就像滑回了它的原始位置。

.scene {
  animation: step-up var(--speed) infinite linear both;
}

@keyframes step-up {
  to {
    transform: translate3d(-100%, 0, var(--depth));
  }
}

哇,这花了很少的功夫

我们可以从.scene.plane 删除平台的颜色,以防止动画过于刺眼。

有趣的变化

一个 "翻转 "的效果如何?我的意思是让Slink以不同的方式滑行。如果我们在场景中添加一个额外的包装元素,我们可以通过180deg 在每个slink上旋转场景。

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

我喜欢它!当然,造型是主观的......所以,我做了一个小应用程序,你可以使用配置你的Slinky。

这里是 "原始 "和 "Flip-Flop "版本,我用阴影和主题做了一些进一步的处理。

这就是了!

这至少是一种制作纯CSS Slinky的方法,它既是3D的又是可配置的。当然,你可能不会每天都去找这样的东西,但它带来了有趣的CSS动画技术。它还提出了一个问题:在CSS中设置一个animation-repeat-delay 属性是否有用。你怎么看?你认为它是否会有一些好的使用案例?我很想知道。

请一定要玩一玩这些代码--所有的代码都可以在这个CodePen Collection中找到。