CSS 中的动画套娃

Avatar of Jhey Tompkins
Jhey Tompkins

DigitalOcean 为您旅程的每个阶段提供云产品。从 200 美元的免费信用 开始!

这是一个有趣的内容。我们如何用 CSS 创建一组那些很酷的俄罗斯套娃,它们可以互相嵌套?

我在脑海里琢磨了这个想法一段时间。然后,我看到了来自 CSS-Tricks 的一条推文,文章图片上有那些套娃。我把它看作一个信号!是时候把手指放到键盘上了。

我们的目标是让这些套娃变得有趣且互动,我们可以点击一个套娃打开它,露出另一个更小的套娃。哦,并且只用 CSS 来实现功能。而且,我们还可以用我们自己的角色来替换套娃,比如一个 CodePen 熊。就像这样

我们不会一开始就花时间把东西弄得漂亮。让我们先在页面上添加一些标记,并先解决机制。

我们不能有无限数量的套娃。当我们到达最里面的套娃时,最好能够重置套娃,而无需刷新页面。一个巧妙的技巧是将我们的场景包裹在一个 HTML 表单中。这样,我们可以使用一个输入并将其 type 属性设置为 reset 来避免使用任何 JavaScript。

<form>
  <input type="reset" id="reset"/>
  <label for="reset" title="Reset">Reset</label>
</form>

接下来,我们需要一些套娃。或者熊。或者任何东西都可以开始。关键是要使用经典的 复选框技巧 和任何相关的表单标签。需要注意的是,我将使用 Pug 来处理标记,因为它支持循环,使事情变得更轻松。但是,您当然也可以手动编写 HTML。以下是使用表单字段和标签来设置复选框技巧的开始。

尝试点击一些输入,然后点击“重置”输入。它们都将变成未选中状态。不错,我们将利用这一点。

我们有一些交互,但目前还没有真正发生任何事情。以下是计划

  1. 我们一次只显示一个复选框
  2. 选中一个复选框应该会显示下一个复选框的标签。
  3. 当我们到达最后一个复选框时,我们唯一的选择应该是重置表单。

技巧是利用 CSS 相邻兄弟选择器 (+)。

input:checked + label + input + label {
  display: block;
}

当一个复选框被选中时,我们需要显示下一个套娃的标签,它将在 DOM 中与当前套娃相隔三个兄弟元素。我们如何使第一个标签可见?通过在我们的标记中使用内联样式,给它一个显式的 display: block。将这些放在一起,我们得到类似这样的代码

点击每个标签都会显示下一个标签。等等,最后一个标签没有显示!这是正确的。这是因为最后一个标签没有复选框。我们需要添加一个专门针对最后一个标签的规则。

input:checked + label + input + label,
input:checked + label + label {
  display: block;
}

酷。我们正在接近。这就是基本机制。现在事情将变得有点棘手。 

基本样式

所以,您可能会想,“为什么我们不隐藏选中的标签?” 好问题!但是,如果我们直接隐藏它,我们就不會有任何当前套娃到下一个套娃之间的过渡。在我们开始为套娃设置动画之前,让我们创建一些基本的方框来代表套娃。我们可以对它们进行样式设置,使它们模拟套娃的外观,但没有细节。

.doll {
  color: #fff;
  cursor: pointer;
  height: 200px;
  font-size: 2rem;
  left: 50%;
  position: absolute;
  text-align: center;
  top: 50%;
  transform: translate(-50%, -50%);
  width: 100px;
}

.doll:nth-of-type(even) {
  background: #00f;
}

.doll:nth-of-type(odd) {
  background: #f00;
}

点击一个套娃会立即显示下一个套娃,并且当我们到达最后一个套娃时,我们可以重置表单以重新开始。这就是我们想要的效果。

机制

我们将根据一个中心点来为套娃设置动画。我们的动画将包含许多步骤

  1. 将当前套娃向左滑动。
  2. 打开套娃以显示下一个套娃。
  3. 将下一个套娃移动到当前套娃的起始位置。
  4. 使当前套娃淡出。
  5. 将下一个套娃指定为当前套娃。

让我们从将当前套娃向左滑动开始。我们在点击标签时应用动画。使用 :checked 伪选择器,我们可以定位当前套娃。在这一点上,值得注意的是,我们将使用 CSS 变量来控制动画速度和行为。这将使我们更容易将动画链接到标签上。

:root {
  --speed: 0.25;
  --base-slide: 100;
  --slide-distance: 60;
}

input:checked + label {
  animation: slideLeft calc(var(--speed) * 1s) forwards;
}

@keyframes slideLeft {
  to {
    transform: translate(calc((var(--base-slide) * -1px) + var(--slide-distance) * -1%), 0);
  }
}

看起来不错。但是有一个问题。只要我们点击一个标签,我们就可以再次点击它并重置动画。我们不希望那样发生。

我们如何解决这个问题?我们可以在标签被点击后,从它身上移除指针事件。

input:checked + label {
  animation: slideLeft calc(var(--speed) * 1s) forwards;
  pointer-events: none;
}

太好了!现在,一旦我们开始,我们就不能停止动画链的执行。

接下来,我们需要打开套娃以显示下一个套娃。这部分比较难,因为我们需要一些额外的元素,不仅是为了创建套娃打开的效果,还要显示它里面的下一个套娃。没错:我们需要复制内部套娃。这里的技巧是显示一个“假的”套娃,我们在为它设置动画后,将它替换为真实的套娃。这也意味着延迟显示下一个标签。

现在我们的标记更新标签,使它们包含 span 元素。

<label class="doll" for="doll--1">
  <span class="doll doll--dummy"></span>
  <span class="doll__half doll__half--top">Top</span>
  <span class="doll__half doll__half--bottom">Bottom</span>
</label>

这些将充当“伪”套娃,以及当前套娃的盖子和底座。

.doll {
  color: #fff;
  cursor: pointer;
  height: 200px;
  font-size: 2rem;
  position: absolute;
  text-align: center;
  width: 100px;
}

.doll:nth-of-type(even) {
  --bg: #00f;
  --dummy-bg: #f00;
}

.doll:nth-of-type(odd) {
  --bg: #f00;
  --dummy-bg: #00f;
}

.doll__half {
  background: var(--bg);
  position: absolute;
  width: 100%;
  height: 50%;
  left: 0;
}

.doll__half--top {
  top: 0;
}

.doll__half--bottom {
  bottom: 0;
}

.doll__dummy {
  background: var(--dummy-bg);
  height: 100%;
  width: 100%;
  position: absolute;
  top: 0;
  left: 0;
}

盖子需要三个平移来创建打开效果:一个向上弹出,一个向左移动,然后一个向下弹出。

@keyframes open {
  0% {
    transform: translate(0, 0);
  }
  33.333333333333336% {
    transform: translate(0, -100%);
  }
  66.66666666666667% {
    transform: translate(-100%, -100%);
  }
  100% {
    transform: translate(-100%, 100%);
  }
}

接下来,我们可以使用 CSS 自定义属性来处理更改值。一旦套娃向左滑动,我们就可以打开它。但是我们如何知道在它滑到左边后,要延迟多长时间才能打开它?我们可以使用之前定义的 --speed 自定义属性来计算正确的延迟。

如果我们使用 --speed 的值,看起来有点快,所以让我们将其乘以两秒钟

input:checked + .doll {
  animation: slideLeft calc(var(--speed) * 1s) forwards;
  pointer-events: none;
}

input:checked + .doll .doll__half--top {
  animation: open calc(var(--speed) * 2s) calc(var(--speed) * 1s) forwards; // highlight
}

好多了

现在我们需要将内部的“伪”套娃移动到新位置。这个动画与打开动画类似,它也包含三个阶段。同样,这三个阶段分别是向上移动,向右移动,然后向下放置。它也与滑动动画类似。我们将使用 CSS 自定义属性来确定套娃移动的距离。

:root {
  // Introduce a new variable that defines how high the dummy doll should pop out.
  --pop-height: 60;
}

@keyframes move {
  0% {
    transform: translate(0, 0) translate(0, 0);
  }
  33.333333333333336% {
    transform: translate(0, calc(var(--pop-height) * -1%)) translate(0, 0);
  }
  66.66666666666667% {
    transform: translate(0, calc(var(--pop-height) * -1%)) translate(calc((var(--base-slide) * 1px) + var(--slide-distance) * 1%), 0);
  }
  100% {
    transform: translate(0, calc(var(--pop-height) * -1%)) translate(calc((var(--base-slide) * 1px) + var(--slide-distance) * 1%), calc(var(--pop-height) * 1%));
  }
}

差不多了! 

唯一的问题是,下一个套娃会在我们点击套娃后立即显示出来。这意味着我们可以连续点击,快速浏览完所有套娃。

严格来说,下一个套娃应该在“伪”套娃移动到位之前才显示出来。只有当“伪”套娃移动到位后,我们才能隐藏它并显示真实的套娃。这意味着我们将使用零秒缩放动画!没错。我们可以通过延迟两个零秒动画并使用 animation-fill-mode 来假装。

@keyframes appear {
  from {
    transform: scale(0);
  }
}

实际上我们只需要一组 @keyframes,因为我们可以重新使用已有的代码,通过使用 animation-direction: reverse 来创建相反的运动。考虑到这一点,我们所有的动画都会像这样应用

// The next doll
input:checked + .doll + input + .doll,
// The last doll (doesn't have an input)
input:checked + .doll + .doll {
  animation: appear 0s calc(var(--speed) * 5s) both;
  display: block;
}

// The current doll
input:checked + .doll,
// The current doll that isn't the first. Specificity prevails
input:checked + .doll + input:checked + .doll {
  animation: slideLeft calc(var(--speed) * 1s) forwards;
  pointer-events: none;
}

input:checked + .doll .doll__half--top,
input:checked + .doll + input:checked + .doll .doll__half--top {
  animation: open calc(var(--speed) * 2s) calc(var(--speed) * 1s) forwards;
}

input:checked + .doll .doll__dummy,
input:checked + .doll + input:checked + .doll .doll__dummy {
  animation: move calc(var(--speed) * 2s) calc(var(--speed) * 3s) forwards, appear 0s calc(var(--speed) * 5s) reverse forwards;
}

请注意变量的重要性,尤其是在我们链接动画的地方。这让我们几乎达到了目标。

我现在能听到有人说:“它们都一样大!” 是的。那就是缺失的部分。它们需要缩小。这里的技巧是再次调整标记,并再次使用 CSS 自定义属性。

<input id="doll--0" type="checkbox"/>
<label class="doll" for="doll--0" style="display: block; --doll-index: 0;">
  <span class="doll__dummy-container">
    <span class="doll__dummy"></span>
  </span> //highlight
  <span class="doll__container">
    <span class="doll__half doll__half--top"></span>
    <span class="doll__half doll__half--bottom"></span>
  </span>
</label>

我们只是在内联中引入了一个 CSS 自定义属性,它告诉我们套娃的索引。我们可以使用它来生成每个半部分的比例,以及伪内部套娃的比例。半部分需要缩放到与实际套娃大小一致,但伪内部套娃的比例需要与下一个套娃的比例一致。很棘手!

我们可以在容器内部应用这些比例,这样就不会影响我们的动画。

:root {
  --scale-step: 0.05;
}

.doll__container,
.doll__dummy-container {
  height: 100%;
  left: 0;
  position: absolute;
  top: 0;
  width: 100%;
}

.doll__container {
  transform: scale(calc(1 - ((var(--doll-index)) * var(--scale-step))));
  transform-origin: bottom;
}

.doll__dummy {
  height: 100%;
  left: 0;
  position: absolute;
  top: 0;
  transform: scale(calc(1 - ((var(--doll-index) + 1) * var(--scale-step))));
  transform-origin: bottom center;
  width: 100%;
}

请注意,.doll__dummy 类使用 var(--doll-index) + 1) 来计算比例,使其与下一个套娃的比例匹配。 👍

最后,我们将动画重新分配给 .doll__dummy-container 类,而不是 .doll__dummy 类。

input:checked + .doll .doll__dummy-container,
input:checked + .doll + input:checked + .doll .doll__dummy-container {
  animation: move calc(var(--speed) * 2s) calc(var(--speed) * 3s) forwards, appear 0s calc(var(--speed) * 5s) reverse forwards;
}

以下是一个演示,其中容器被赋予了背景颜色,以便查看发生了什么。

我们可以看到,虽然内容大小发生了变化,但它们保持了相同的大小。这使得动画行为保持一致,并使代码更容易维护。

收尾工作

哇,事情看起来很光滑!我们只需要进行一些收尾工作,我们就完成了!

场景开始显得杂乱,因为当引入新的套娃时,我们将“旧”套娃堆叠在一边。所以让我们在显示下一个套娃时,将一个套娃滑出视野,以清理这些混乱。

@keyframes slideOut {
  from {
    transform: translate(calc((var(--base-slide) * -1px) + var(--slide-distance) * -1%), 0);
  }
  to {
    opacity: 0;
    transform: translate(calc((var(--base-slide) * -1px) + var(--slide-distance) * -2%), 0);
  }
}

input:checked + .doll,
input:checked + .doll + input:checked + .doll {
  animation: slideLeft calc(var(--speed) * 1s) forwards,
    slideOut calc(var(--speed) * 1s) calc(var(--speed) * 6s) forwards;
  pointer-events: none;
}

新的 slideOut 动画在套娃向左平移的同时,让它淡出。完美。 👍

这就是我们使这种效果生效所需的 CSS 技巧。剩下的就是为套娃和场景设置样式。

我们有很多选项可以为套娃设置样式。我们可以使用背景图片、CSS 插图、SVG 等等。我们甚至可以拼凑一些使用随机内联色调的 emoji 套娃!

让我们使用内联 SVG。

我基本上使用的是我们已经介绍过的相同的基础机制。不同之处在于,我还为色调和亮度生成了内联变量,这样熊就穿着不同的颜色衬衫。


我们完成了!用 HTML 和 CSS 制作的俄罗斯套娃——嗯,熊!所有步骤的所有代码都可以在 这个 CodePen 集合 中找到。有什么问题或建议?请随时在评论区联系我。