How to Make a Pure CSS 3D Package Toggle | CSS-Tricks

You know how you can get cardboard boxes that turn completely flat? You fold it and tap it to turn it into a handy box. Then when it’s time to recycle it, cut it back up to flatten it. Recently someone contacted me about this concept mainly as 3D animation and I thought it would be an interesting tutorial to do it all in CSS, so here we are!

What would that animation look like? How do we create this mobilization schedule? Can sizing be flexible? Let’s swap out a pure CSS package.

This is what we are working towards. Click to fill and unpack the carton.

from where we start?

Where do you even start with something like this? It is better to plan ahead. We know we will have a template for our package. This will need to be folded in three dimensions. If working with 3D in CSS is new to you, I recommend this article to get you started.

If you’re familiar with 3D CSS, it can be tempting to create a cuboid and go from there. But this will pose some problems. We need to think about how the beam goes from 2D to 3D.

Let’s start by creating a template. We need to plan ahead with our tags and think about how we want the fill animation to work. Let’s start with some 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>

Mixins is a good idea

There is a lot going on there. It’s a lot of divs. I often like to use Pug to create tags so I can break things down into reusable blocks. For example, each side will have two slips. We can create a clay mix for the sides and use attributes to apply a modified class name to make all these tags easier to write.

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

We use two cells. Someone creates the panels for each side of the box. The other creates the sides of the box. note in file side We make use of mixin block. This is where the mixin kids are shown which is especially useful, as we need to overlap some aspects to make our life easier later on.

Our generated codec:

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

nesting sides

Nesting the sides makes it easy to fold our package. Much like each side has two panels. The sons of the side can inherit the transformations of the two sides and then apply their own transformations. If we start with a cuboid, it will be difficult to take advantage of that.

Screenshot showing HTML markup on the left and unfolded cardboard box on the right.  The sign shows how one side of the box is a main container that defines the wide side of the box and contains subsidiary elements of the corresponding upper and lower sheets.  The orange arrows link each element to the visual display to indicate which parts of the box in the HTML correspond to the visual display.

Check out this demo that flips between nested and non-nested elements to see the difference in the procedure.

Each box contains a file transform-origin Set down the right corner with 100% 100%. Selecting the “Transform” toggle rotates each square 90deg. But look how it behaves transform It changes if we overlap the elements.

We flip between two versions of the coding but don’t change anything else.

crossed:

<div class="boxes boxes--nested">
  <div class="box">
    <div class="box">
      <div class="box">
        <div class="box"></div>
      </div>
    </div>
  </div>
</div>

non-overlapping:

<div class="boxes">
  <div class="box"></div>
  <div class="box"></div>
  <div class="box"></div>
  <div class="box"></div>
</div>

Transform all things

After applying some styling to our HTML, we have our package template.

Patterns define the different colors and position the sides of the package. Each side gets a site related to the “main” side. (You’ll see why all this overlap is useful in a moment.)

There are some things you should be aware of. We use pretty much like working with semi-rectangles --heightAnd --width, And --depth sizing variables. This will make it easier to change the size of our package.

.package {
  height: calc(var(--height, 20) * 1vmin);
  width: calc(var(--width, 20) * 1vmin);
}

Why specify the size like this? We use a default size without a unit of 20, an idea I picked up from Lea Verou’s 2016 CSS ConfAsia talk (starting at 52:44). By using custom properties as ‘data’ instead of ‘values’, we are free to do whatever we want with them calc(). In addition, JavaScript does not have to care about value units and we can change to pixels, percentage, etc., without having to make changes elsewhere. You can reformat this to an operand in --rootBut it soon becomes an exaggeration.

The panels for each side also need a smaller size than the sides they are part of. This is so that we can see a slight gap as if we were in real life. Also, the panels on the sides should be slightly lowered. That’s even when we fold it, we don’t get it z-index fighting between them.

.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%;
}

We’ve also started looking at transform-origin for individual pieces. You will rotate an upper tongue from its lower edge and a lower flap from its upper edge.

We can use a pseudo-element for the tab on the right side. we use clip-path to obtain the desired shape.

.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%;
}

Let’s start working with our template on a 3D plane. We can start to rotate .scene on the X and Y axis.

.scene {
  transform: rotateX(-24deg) rotateY(-32deg) rotateX(90deg);
}

folding up

We’re ready to start folding our model! Our form will be folded based on a custom property, --packaged. If the value 1, then we can fold the model. For example, let’s fold some sides and the pseudo-element tab.

.package__side--tabbed,
.package__side--tabbed:after {
  transform: rotateY(calc(var(--packaged, 0) * -90deg)); 
}
.package__side--extra {
  transform: rotateY(calc(var(--packaged, 0) * 90deg));
}

Or we can write a rule for all non-“main” parties.

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

This will cover all aspects.

Remember when I said that overlapping sides allow us to inherit a parent’s transformation? If we update our demo so we can change the value of --packaged, we can see how the value affects the transformations. Try moving a file --packaged between . value 1 And 0 And you’ll see exactly what I mean.

Now that we have a way to switch the fold state of our template, we can start working on some movement. Our previous offer flips between the two states. We can take advantage of transition For this. fastest way? Add transition to me transform of every child in .scene.

.scene *,
.scene *::after {
  transition: transform calc(var(--speed, 0.2) * 1s);
}

Multistep transitions!

But we don’t fold the entire die in one go – in real life there is a sequence where we fold one side and flap it first and then move on to the next, and so on. Scope custom properties are ideal for this.

.scene *,
.scene *::after {
  transition: transform calc(var(--speed, 0.2) * 1s) calc((var(--step, 1) * var(--delay, 0.2)) * 1s);
}

Here we say it, each transition, use transition-delay From --step multiplied by --delay. the --delay The value will not change but each element can determine which “step” is in the sequence. And then we can be honest about the order in which things happen.

.package__side--extra {
  --step: 1;
}
.package__side--tabbed {
  --step: 2;
}
.package__side--flipped,
.package__side--tabbed::after {
  --step: 3;
}

Consider the following demo to get a better idea of ​​how this works. Change the slider values ​​to update the order in which things happen. Can you change which car wins?

The same technique is the key to what we are going to achieve. We can even provide a file --initial-delay Adds a slight pause to everything for more realism.

.race__light--animated,
.race__light--animated:after,
.car {
  animation-delay: calc((var(--step, 0) * var(--delay-step, 0)) * 1s);
}

If we look at our package, we can take this even further and apply a “step” to all the items you’ll be moving on transform. It’s quite verbose but does the job. Alternatively, you can include these values ​​in the markup.

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

But it doesn’t look very realistic.

Maybe we should turn the box over too

If I were folding the box in real life, I would probably flip the box over before folding the top flaps. How can we do that? Well, those with a keen eye might have noticed .package__wrapper part. We’ll use this to animate the packet. Then we rotate the beam on the x axis. This will create the impression of the package being turned on its side.

.package {
  transform-origin: 50% 100%;
  transform: rotateX(calc(var(--packaged, 0) * -90deg));
}
.package__wrapper {
  transform: translate(0, calc(var(--packaged, 0) * -100%));
}

Adjust --step Accordingly, ads give us something like this.

unfold the box

If you switch between the folded and unfolded cases, you will notice that the fold opening does not look right. The detection sequence must be the exact opposite of the folding sequence. We can flip a file --step Based on --packaged and number of steps. Our last step is 15. we can update transition to this:

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

This is completely out of the mouth calc to reverse transition-delay. But it works! We must remind ourselves to conserve it --no-of-steps Valuable so far!

We have another option. As we continue down the “pure CSS” path, we will eventually benefit from hack checkbox To switch between foldable states. We can have two sets of ‘steps’ selected where one set is active when our checkbox is selected. It’s definitely a more elaborate solution. But it gives us more limited control.

/* Folding */
:checked ~ .scene .package__side--extra {
  --step: 1;
}
/* Unfolding */
.package__side--extra {
  --step: 15;
}

Sizing and centering

Before we give up using [dat.gui](https://github.com/dataarts/dat.gui) In our demo, let’s play with our package size. We want to check that our bundle stays centered while folding and flipping. In this offer, the package has the largest --height and the .scene It has intermittent boundaries.

We may also modify files transform To better center the package while we’re at it:

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

This gives us reliable centering of the scene. It all comes down to preference though!

Add hack checkbox

Let’s go now dat.gui Out of the way and make this CSS “pure”. For this, we need to provide a set of controls in HTML. We will use a checkbox to collapse and open our package. Then we will use a file radio button to select the package size.

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

In the final view, we will hide the input and make use of the label elements. For now, let’s leave them all visible. The trick is to use sibling combination (~) When you get some controls :checked. We can then set custom property values ​​to .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;
}

And here is the presentation with this work!

final polishing

Now we’re in place to make things look “pretty” and add a few extra touches. Let’s start by hiding all the entries.

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

We can design sizing options as round buttons:

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

We want to be able to click anywhere to switch between folding and opening our package. so we have .open And .close The ratings will fill the screen. I wonder why we have two labels? It’s a little trick. If we use a file transition-delay And scaling the appropriate label, we can hide both labels during package transitions. This is how we combat unwanted clicking (although it will not prevent the user from pressing the space bar on the keyboard).

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

Check out this demo to see where we’ve added background-color all over .open And .close. None of the label appears during the transition.

We have full functionality! But our group is a bit disappointed at the moment. Let’s add extra details to make things more “box” – like things like parcel tape and packing labels.

Small details like this are only limited by our imagination! We can use files --packaged A property intended to affect anything. For example, file .package__tape . is moved scaleY Transformation:

.package__tape {
  transform: translate3d(-50%, var(--offset-y), -2px) scaleX(var(--packaged, 0));
}

The thing to remember is that when we add a new feature that affects the sequence, we need to update our steps. Not only --step values, but also --no-of-steps the value.

This is!

This is how you do a pure CSS 3D package swap. Are you going to drop this in your website? Unlikely! But it’s fun to see how you can achieve these things with CSS. Custom properties are very powerful.

Why not get super festive and give a CSS gift!

Stay cool! ʕ • ᴥ • ʔ

Leave a Comment