您知道那些完全平坦的纸板箱吗?您可以将它们折叠起来并用胶带粘住,制成一个实用的箱子。 然后,当需要回收它们时,您可以将其裁剪开并压平。 最近,有人联系我询问这种概念的 3D 动画,我认为将其完全用 CSS 实现会是一个有趣的教程,所以我们现在就开始吧!
这种动画可能是什么样的?我们如何创建这种包装时间线?尺寸可以灵活调整吗?让我们制作一个纯 CSS 包裹切换动画。
以下是我们想要实现的效果。 点击打包和解包纸板箱。
从哪里开始?
从哪里开始做这样的东西?最好提前计划。 我们知道我们会为我们的包裹制作一个模板。 并且需要在三个维度上进行折叠。 如果您不熟悉 CSS 中的 3D,我建议您阅读 这篇文章 来入门。
如果您熟悉 3D CSS,您可能很想构建一个长方体并从那里开始。 但是,这会带来一些问题。 我们需要考虑包裹是如何从 2D 变成 3D 的。
让我们从创建一个模板开始。 我们需要提前计划好我们的标记,并思考我们希望包装动画如何工作。 让我们从一些 HTML 代码开始。
<div class="scene">
<div class="package__wrapper">
<div class="package">
<div class="package__side package__side--main">
<div class="package__flap package__flap--top"></div>
<div class="package__flap package__flap--bottom"></div>
<div class="package__side package__side--tabbed">
<div class="package__flap package__flap--top"></div>
<div class="package__flap package__flap--bottom"></div>
</div>
<div class="package__side package__side--extra">
<div class="package__flap package__flap--top"></div>
<div class="package__flap package__flap--bottom"></div>
<div class="package__side package__side--flipped">
<div class="package__flap package__flap--top"></div>
<div class="package__flap package__flap--bottom"></div>
</div>
</div>
</div>
</div>
</div>
</div>
混合是一个好主意
这里发生的事情相当多。 很多 div
元素。 我经常喜欢使用 Pug 来生成标记,这样我就可以将它们分成可重用的块。 例如,每一面都会有两个折页。 我们可以为每一面创建一个 Pug 混合,并使用属性应用一个修饰符类名,这样所有这些标记就会更容易编写。
mixin flaps()
.package__flap.package__flap--top
.package__flap.package__flap--bottom
mixin side()
.package__side(class=`package__side--${attributes.class || 'side'}`)
+flaps()
if block
block
.scene
.package__wrapper
.package
+side()(class="main")
+side()(class="tabbed")
+side()(class="extra")
+side()(class="flipped")
我们正在使用两个混合。 一个创建箱子每一面的折页。 另一个创建箱子的每一面。 请注意,在 side
混合中,我们正在使用 block
。 混合使用时,子元素将在那里渲染,这特别有用,因为我们需要嵌套一些侧面,以方便我们后续的操作。
我们生成的标记
<div class="scene">
<div class="package__wrapper">
<div class="package">
<div class="package__side package__side--main">
<div class="package__flap package__flap--top"></div>
<div class="package__flap package__flap--bottom"></div>
<div class="package__side package__side--tabbed">
<div class="package__flap package__flap--top"></div>
<div class="package__flap package__flap--bottom"></div>
</div>
<div class="package__side package__side--extra">
<div class="package__flap package__flap--top"></div>
<div class="package__flap package__flap--bottom"></div>
<div class="package__side package__side--flipped">
<div class="package__flap package__flap--top"></div>
<div class="package__flap package__flap--bottom"></div>
</div>
</div>
</div>
</div>
</div>
</div>
嵌套侧面
嵌套侧面使得更容易折叠我们的包裹。 就像每一面都有两个折页一样。 侧面的子元素可以继承侧面的变换,然后应用自己的变换。 如果我们从长方体开始,很难利用这一点。

查看此演示,它在嵌套和非嵌套元素之间切换,以查看实际效果。
每个箱子的 transform-origin
设置为右下角,使用 100% 100%
。 选中“变换”切换会将每个箱子旋转 90deg
。 但是,请注意,如果我们嵌套元素,transform
的行为会发生变化。
我们在这两个标记版本之间切换,但没有更改其他任何内容。
嵌套
<div class="boxes boxes--nested">
<div class="box">
<div class="box">
<div class="box">
<div class="box"></div>
</div>
</div>
</div>
</div>
非嵌套
<div class="boxes">
<div class="box"></div>
<div class="box"></div>
<div class="box"></div>
<div class="box"></div>
</div>
变换所有东西
在对 HTML 应用了一些样式后,我们就有了我们的包裹模板。
样式指定了不同的颜色,并将侧面定位到包裹上。 每个侧面都相对于“主”侧面得到一个位置。(您会看到稍后所有这些嵌套为什么有用。)
有一些需要注意的地方。 就像使用长方体一样,我们使用 --height
、--width
和 --depth
变量来进行大小调整。 这将使我们更容易在以后更改包裹的大小。
.package {
height: calc(var(--height, 20) * 1vmin);
width: calc(var(--width, 20) * 1vmin);
}
为什么要这样定义大小? 我们使用无单位的默认大小 20
,这个想法来自 Lea Verou 的 2016 年 CSS ConfAsia 演讲(从 52:44 开始)。 使用自定义属性作为“数据”而不是“值”,我们就可以使用 calc()
自由地做任何我们想做的事情。 此外,JavaScript 不需要关心值的单位,我们可以更改为像素、百分比等,而无需在其他地方进行更改。 您也可以将其重构为 --root
中的一个系数,但这可能会很快变得过分。
每一面的折页也需要比它们所属的侧面略微小一些。 这样,当我们折叠它们时,我们就可以看到一个微小的间隙,就像现实生活中一样。 此外,两侧的折页需要稍稍低一些。 这样,当我们折叠它们时,它们之间就不会发生 z-index
冲突。
.package__flap {
width: 99.5%;
height: 49.5%;
background: var(--flap-bg, var(--face-4));
position: absolute;
left: 50%;
transform: translate(-50%, 0);
}
.package__flap--top {
transform-origin: 50% 100%;
bottom: 100%;
}
.package__flap--bottom {
top: 100%;
transform-origin: 50% 0%;
}
.package__side--extra > .package__flap--bottom,
.package__side--tabbed > .package__flap--bottom {
top: 99%;
}
.package__side--extra > .package__flap--top,
.package__side--tabbed > .package__flap--top {
bottom: 99%;
}
我们也开始考虑各个部分的 transform-origin
。 顶部折页将从其底部边缘旋转,底部折页将从其顶部边缘旋转。
我们可以使用伪元素来创建右侧的标签。 我们使用 clip-path
来获得我们想要的形状。
.package__side--tabbed:after {
content: '';
position: absolute;
left: 99.5%;
height: 100%;
width: 10%;
background: var(--face-3);
-webkit-clip-path: polygon(0 0%, 100% 20%, 100% 80%, 0 100%);
clip-path: polygon(0 0%, 100% 20%, 100% 80%, 0 100%);
transform-origin: 0% 50%;
}
让我们开始在 3D 平面上使用我们的模板。 我们可以从在 X 和 Y 轴上旋转 .scene
开始。
.scene {
transform: rotateX(-24deg) rotateY(-32deg) rotateX(90deg);
}
折叠起来
我们准备好开始折叠我们的模板了! 我们的模板将根据一个自定义属性 --packaged
折叠。 如果值为 1
,那么我们就可以折叠模板。 例如,让我们折叠一些侧面和伪元素标签。
.package__side--tabbed,
.package__side--tabbed:after {
transform: rotateY(calc(var(--packaged, 0) * -90deg));
}
.package__side--extra {
transform: rotateY(calc(var(--packaged, 0) * 90deg));
}
或者,我们可以为所有不是“主”侧面的侧面编写一个规则。
.package__side:not(.package__side--main),
.package__side:not(.package__side--main):after {
transform: rotateY(calc((var(--packaged, 0) * var(--rotation, 90)) * 1deg));
}
.package__side--tabbed { --rotation: -90; }
这样就可以覆盖所有侧面。
还记得我说嵌套侧面可以让我们继承父元素的变换吗? 如果我们更新我们的演示,以便我们可以更改 --packaged
的值,我们就可以看到该值如何影响变换。 尝试将 --packaged
的值在 1
和 0
之间滑动,您就会明白我的意思。
现在我们有了切换模板折叠状态的方法,我们可以开始进行一些运动。 我们之前的演示在两种状态之间切换。 我们可以使用 transition
来实现这一点。 最快的方法? 在 .scene
中的每个子元素的 transform
上添加一个 transition
。
.scene *,
.scene *::after {
transition: transform calc(var(--speed, 0.2) * 1s);
}
多步骤过渡!
但是我们不会一次性将模板全部折叠起来——在现实生活中,有一个顺序,我们会先折叠一面和它的折页,然后继续折叠下一面,依此类推。 范围内的自定义属性非常适合这种情况。
.scene *,
.scene *::after {
transition: transform calc(var(--speed, 0.2) * 1s) calc((var(--step, 1) * var(--delay, 0.2)) * 1s);
}
这里,我们说对于每个 transition
,使用 transition-delay
为 --step
乘以 --delay
。 --delay
的值不会改变,但每个元素都可以定义它在序列中的“步骤”。 然后,我们可以明确地指定事情发生的顺序。
.package__side--extra {
--step: 1;
}
.package__side--tabbed {
--step: 2;
}
.package__side--flipped,
.package__side--tabbed::after {
--step: 3;
}
考虑以下演示,以更好地了解其工作原理。 更改滑块的值以更新事情发生的顺序。 您能更改哪辆车获胜吗?
同样的技术对于我们即将做的事情至关重要。 我们甚至可以引入一个 --initial-delay
,为所有内容添加一个短暂的暂停,以使效果更加逼真。
.race__light--animated,
.race__light--animated:after,
.car {
animation-delay: calc((var(--step, 0) * var(--delay-step, 0)) * 1s);
}
如果我们回过头看看我们的包裹,我们可以更进一步,为所有将要 transform
的元素应用一个“步骤”。 它相当冗长,但可以完成任务。 或者,您也可以在标记中内联这些值。
.package__side--extra > .package__flap--bottom {
--step: 4;
}
.package__side--tabbed > .package__flap--bottom {
--step: 5;
}
.package__side--main > .package__flap--bottom {
--step: 6;
}
.package__side--flipped > .package__flap--bottom {
--step: 7;
}
.package__side--extra > .package__flap--top {
--step: 8;
}
.package__side--tabbed > .package__flap--top {
--step: 9;
}
.package__side--main > .package__flap--top {
--step: 10;
}
.package__side--flipped > .package__flap--top {
--step: 11;
}
但是,它感觉不太逼真。
也许我们也应该翻转一下箱子
如果我在现实生活中折叠箱子,我可能会先将箱子翻转过来,然后再折叠顶部折页。 那么我们如何做到这一点呢? 那些目光敏锐的人可能已经注意到 .package__wrapper
元素了。 我们将使用它来滑动包裹。 然后,我们将包裹在 x 轴上旋转。 这将产生将包裹翻转到侧面的印象。
.package {
transform-origin: 50% 100%;
transform: rotateX(calc(var(--packaged, 0) * -90deg));
}
.package__wrapper {
transform: translate(0, calc(var(--packaged, 0) * -100%));
}
相应地调整 --step
声明,我们可以得到这样的效果。
展开盒子
如果您在折叠和未折叠状态之间切换,您会注意到展开看起来不对。展开顺序应与折叠顺序完全相反。我们可以根据 `--packaged` 和步骤数来翻转 `--step`。我们最新的步骤是 `15`。我们可以将我们的 `transition` 更新为这个
.scene *,
.scene *:after {
--no-of-steps: 15;
--step-delay: calc(var(--step, 1) - ((1 - var(--packaged, 0)) * (var(--step) - ((var(--no-of-steps) + 1) - var(--step)))));
transition: transform calc(var(--speed, 0.2) * 1s) calc((var(--step-delay) * var(--delay, 0.2)) * 1s);
}
这是反转 `transition-delay` 的一大串 `calc`。但是,它起作用了!我们必须提醒自己要保持 `--no-of-steps` 值的最新状态!
我们还有另一个选择。随着我们继续走“纯 CSS”路线,我们最终将使用 复选框技巧 来在折叠状态之间切换。我们可以有两组定义的“步骤”,当我们的复选框被选中时,其中一组处于活动状态。它当然是一个更冗长的解决方案。但是,它确实给了我们更精细的控制。
/* Folding */
:checked ~ .scene .package__side--extra {
--step: 1;
}
/* Unfolding */
.package__side--extra {
--step: 15;
}
大小和居中
在我们放弃在演示中使用 `[dat.gui](https://github.com/dataarts/dat.gui)` 之前,让我们玩玩包装的大小。我们要检查我们的包装在折叠和翻转时是否保持居中。在这个演示中,包装有一个较大的 `--height`,而 `。scene` 有一个虚线边框。
我们不妨调整我们的 `transform` 以在调整包装时更好地居中
/* Considers package height by translating on z-axis */
.scene {
transform: rotateX(calc(var(--rotate-x, -24) * 1deg)) rotateY(calc(var(--rotate-y, -32) * 1deg)) rotateX(90deg) translate3d(0, 0, calc(var(--height, 20) * -0.5vmin));
}
/* Considers package depth by sliding the depth before flipping */
.package__wrapper {
transform: translate(0, calc((var(--packaged, 0) * var(--depth, 20)) * -1vmin));
}
这给了我们在场景中的可靠居中。但这完全取决于个人喜好!
添加复选框技巧
现在让我们摆脱 `dat.gui`,使之成为“纯” CSS。为此,我们需要在 HTML 中引入一堆控件。我们将使用一个复选框来折叠和展开我们的包装。然后我们将使用一个 `radio` 按钮来选择包装尺寸。
<input id="package" type="checkbox"/>
<input id="one" type="radio" name="size"/>
<label class="size-label one" for="one">S</label>
<input id="two" type="radio" name="size" checked="checked"/>
<label class="size-label two" for="two">M</label>
<input id="three" type="radio" name="size"/>
<label class="size-label three" for="three">L</label>
<input id="four" type="radio" name="size"/>
<label class="size-label four" for="four">XL</label>
<label class="close" for="package">Close Package</label>
<label class="open" for="package">Open Package</label>
在最终的演示中,我们将隐藏输入并使用标签元素。不过,现在让我们先把它们都显示出来。诀窍是在某些控件被选中时使用同级组合器(`~`)。然后我们可以在 `。scene` 上设置自定义属性值。
#package:checked ~ .scene {
--packaged: 1;
}
#one:checked ~ .scene {
--height: 10;
--width: 20;
--depth: 20;
}
#two:checked ~ .scene {
--height: 20;
--width: 20;
--depth: 20;
}
#three:checked ~ .scene {
--height: 20;
--width: 30;
--depth: 20;
}
#four:checked ~ .scene {
--height: 30;
--width: 20;
--depth: 30;
}
这里有一个演示,展示了它的工作原理!
最终润色
现在我们可以让它看起来“漂亮”并添加一些额外的修饰。让我们从隐藏所有输入开始。
input {
position: fixed;
top: 0;
left: 0;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
我们可以将大小选项样式化为圆形按钮
.size-label {
position: fixed;
top: var(--top);
right: 1rem;
z-index: 3;
font-family: sans-serif;
font-weight: bold;
color: #262626;
height: 44px;
width: 44px;
display: grid;
place-items: center;
background: #fcfcfc;
border-radius: 50%;
cursor: pointer;
border: 4px solid #8bb1b1;
transform: translate(0, calc(var(--y, 0) * 1%)) scale(var(--scale, 1));
transition: transform 0.1s;
}
.size-label:hover {
--y: -5;
}
.size-label:active {
--y: 2;
--scale: 0.9;
}
我们要能够点击任何地方来在折叠和展开我们的包装之间切换。所以我们的 `。open` 和 `。close` 标签将占据整个屏幕。想知道为什么我们有两个标签?这是一个小技巧。如果我们使用 `transition-delay` 并放大适当的标签,我们可以在包装过渡期间隐藏两个标签。这是我们对抗垃圾点击的方式(即使它不会阻止用户在键盘上按空格键)。
.close,
.open {
position: fixed;
height: 100vh;
width: 100vw;
z-index: 2;
transform: scale(var(--scale, 1)) translate3d(0, 0, 50vmin);
transition: transform 0s var(--reveal-delay, calc(((var(--no-of-steps, 15) + 1) * var(--delay, 0.2)) * 1s));
}
#package:checked ~ .close,
.open {
--scale: 0;
--reveal-delay: 0s;
}
#package:checked ~ .open {
--scale: 1;
--reveal-delay: calc(((var(--no-of-steps, 15) + 1) * var(--delay, 0.2)) * 1s);
}
查看这个演示,看看我们在哪里为 `。open` 和 `。close` 添加了 `background-color`。在过渡期间,这两个标签都不可见。
我们拥有完整的功能!但是,我们的包装现在有点乏味。让我们添加一些额外的细节,使其看起来更像“盒子”,比如包裹胶带和包装标签。
像这样的细微差别只受我们想象力的限制!我们可以使用我们的 `--packaged` 自定义属性来影响任何东西。例如,`。package__tape` 正在过渡 `scaleY` 变换
.package__tape {
transform: translate3d(-50%, var(--offset-y), -2px) scaleX(var(--packaged, 0));
}
需要注意的是,无论何时我们添加一个影响序列的新功能,都需要更新我们的步骤。不仅要更新 `--step` 值,还要更新 `--no-of-steps` 值。
就这样!
这就是制作纯 CSS 3D 包装切换的方式。你会把它放到你的网站上吗?不可能!但是,看到你可以用 CSS 实现这些事情很有趣。自定义属性非常强大。
为什么不超级热闹,送一份 CSS 礼物!
保持精彩!ʕ •ᴥ•ʔ