这是一个有趣的内容。我们如何用 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。以下是使用表单字段和标签来设置复选框技巧的开始。
尝试点击一些输入,然后点击“重置”输入。它们都将变成未选中状态。不错,我们将利用这一点。
我们有一些交互,但目前还没有真正发生任何事情。以下是计划
- 我们一次只显示一个复选框
- 选中一个复选框应该会显示下一个复选框的标签。
- 当我们到达最后一个复选框时,我们唯一的选择应该是重置表单。
技巧是利用 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;
}
点击一个套娃会立即显示下一个套娃,并且当我们到达最后一个套娃时,我们可以重置表单以重新开始。这就是我们想要的效果。
机制
我们将根据一个中心点来为套娃设置动画。我们的动画将包含许多步骤
- 将当前套娃向左滑动。
- 打开套娃以显示下一个套娃。
- 将下一个套娃移动到当前套娃的起始位置。
- 使当前套娃淡出。
- 将下一个套娃指定为当前套娃。
让我们从将当前套娃向左滑动开始。我们在点击标签时应用动画。使用 :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 集合 中找到。有什么问题或建议?请随时在评论区联系我。
很棒的文章!这是一个非常巧妙的想法。
嗨,Jacob!
谢谢。我真的很感谢。很高兴您喜欢它!
我太喜欢了!更多东西应该只用 HTML & CSS 来写,这是一个很好的例子,说明你可以做很多事情。<3
非常感谢你,Tamm!像这样的评论让我的每一天都充满快乐!