如何制作一个纯CSS的3D包装切换器

239 阅读11分钟

你知道你可以得到完全平坦的纸板箱吗?你把它们折叠起来,然后用胶带粘住,使它们成为一个有用的盒子。然后当你要回收它们的时候,你再把它们切开,把它们压平。最近,有人向我提出将这一概念制作成三维动画,我想如果完全用CSS来做,会是一个有趣的教程,所以我们就这样做了!"。

这个动画看起来如何?我们如何创建包装时间线?尺寸是否可以灵活调整?让我们做一个纯CSS的包装切换。

这就是我们的工作目标。点击包装和拆开纸盒。

从哪里开始呢?

像这样的事情,你甚至从哪里开始呢?最好的办法是提前计划。我们知道,我们要为我们的包装准备一个模板。而这将需要在三维空间中折叠起来。如果你对CSS中的三维工作感到陌生,我推荐这篇文章来帮助你开始。

如果你对三维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 mixin中,我们使用了block 。这就是mixin使用的子项被呈现的地方,这特别有用,因为我们需要嵌套一些边,以方便我们以后的生活。

我们生成的标记。

<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>

嵌套的边

嵌套侧面使我们的包更容易折叠起来。就像每个侧面都有两个挡板一样。一个侧面的子代可以继承侧面的变换,然后应用自己的变换。如果我们从一个立方体开始,就很难利用这一点了。

Screenshot showing HTML markup on the left and a rendering the unfolded cardboard box on the right. The markup shows how one of the box’s sides is a parent container that sets the broad side of the box and contains children for the corresponding top and bottom flaps. Orange arrows connect each element to the visual rendering to outline which parts of the box correspond in HTML correspond to the visual rendering.

看看这个在嵌套和非嵌套元素之间翻转的演示,看看其中的区别。

每个盒子都有一个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会议的演讲中获得的(从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%;
}

让我们开始在一个三维平面上使用我们的模板。我们可以先在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 的值,我们可以看到这个值是如何影响变换的。试着在10 之间滑动--packaged 的值,你会看到我的意思。

现在我们有一种方法来切换我们模板的折叠状态,我们可以开始处理一些动作。我们之前的演示在这两种状态之间翻转。我们可以利用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 ,使用--step 乘以--delaytransition-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);
}

CodePen Embed Fallback

如果我们回过头来看我们的包,我们可以更进一步,对所有要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 声明,我们就可以得到这样的东西。 CodePen嵌入回退

展开包装盒

如果你在折叠状态和未折叠状态之间进行翻转,你会发现展开的方式看起来不对。展开的顺序应该是与折叠的顺序完全相反。我们可以根据--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);
}

这是相当大的口气,calc ,以扭转transition-delay 。但是,它是有效的!不过,我们必须提醒自己,保持--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>

在最后的演示中,我们将隐藏输入并使用标签元素。不过现在,让我们让它们全部可见。诀窍是,当某些控件得到:checked ,就使用兄弟姐妹组合器(~)。然后我们可以在.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);
}

这就是了!

这就是你如何制作一个纯CSS的3D包装切换器。你打算把这个放到你的网站上吗?不太可能。但是,看看你如何用CSS实现这些东西是很有趣的。自定义属性是如此强大。

为什么不把它作为一个超级节日,并把它作为CSS的礼物呢?

保持威武!ʕ -ᴥ-ʔ