How to Make a Pure CSS 3D Package Toggle

Avatar of Jhey Tompkins
Jhey Tompkins on (Updated on )

You know how you can get cardboard boxes that come totally flat? You fold ‘em up and tape ‘em to make them into a useful box. Then when it’s time to recycle them, you cut them back apart to flatten them. Recently, someone reached out to me about essentially this concept as a 3D animation and I thought it would make an interesting tutorial to do it entirely in CSS, so here we are!

How might that animation look? How could we create that packing timeline? Could the sizing be flexible? Let’s make a pure CSS package toggle.

Here’s what we’re working towards. Tap to pack and unpack the cardboard box.

Where to start?

Where do you even start with something like this? It’s best to plan ahead. We know we’re going to have a template for our package. And that will need folding up 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 might be tempting to construct a cuboid and go from there. But, that’s going to pose some problems. We need to consider how a package goes from 2D to 3D.

Let’s start by creating a template. We need to plan ahead with our markup and think about how we want our packing 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 are a good idea

There’s quite a bit happening there. It’s a lot of divs. I often like to use Pug for generating markup so I can split things up into reusable blocks. For example, every side will have two flaps. We can create a Pug mixin for the sides and use attributes to apply a modifier class name to make all that markup a lot 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’re using two mixins. One creates the flaps for each side of the box. The other creates the sides of the box. Notice in the side mixin we are making use of block. That is where children of mixin usage get rendered which is particularly useful, as we need to nest some of the sides to make our lives easier later.

Our generated markup:

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

Nesting the sides makes it easier to fold up our package. Much like each side has two flaps. The children of a side can inherit the sides’ transform and then apply their own. If we started with a cuboid, it would be hard to leverage this.

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.

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

Each box has a transform-origin set to the bottom right corner with 100% 100%. Checking the “Transform” toggle rotates each box 90deg. But, see how the behavior of that transform changes if we nest the elements.

We’re flipping between the two versions of markup but not changing anything else.

Nested:

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

Not nested:

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

Transforming all the things

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

The styles specify the different colors and position the sides to the package. Each side gets a position that’s relative to the “main” side. (You’ll see why all that nesting is useful in a moment.)

There are some things to be aware of. Much like working with cuboids, we are using --height, --width, and --depth variables for sizing. This will make it easier to change our package sizing down the line.

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

Why define sizing like this? We are using a unit-less default sizing of 20, an idea I picked up from Lea Verou’s 2016 CSS ConfAsia talk (starting at 52:44). Using custom properties as “data” instead of “values,” we are free to do what we want with them using calc(). Additionally, JavaScript doesn’t have to care about value units and we can change to pixels, a percentage, etc., without having to make changes elsewhere. You could refactor this into a coefficient in the --root, but it could also quickly become overkill.

The flaps for each side also need a size ever so smaller than the sides they are a part of. This is so we can see a slight gap as we would in real life. Also, the flaps on two sides need to sit a little lower. This is so that when we fold them up, we don’t get 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’re also starting to consider the transform-origin for the individual pieces. A top flap will rotate from its bottom edge and a bottom flap will rotate from its top edge.

We can use a pseudo-element for the tab on that right side. We are using clip-path to get that 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 by rotating the .scene on the X and Y axis.

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

Folding up

We’re ready to start folding up our template! Our template will fold up based on a custom property, --packaged. If the value is 1, then we can fold up the template. For example, let’s fold some of the 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 could write a rule for all sides that aren’t the “main” one.

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

And that would cover all the sides.

Remember when I said the nested sides allow us to inherit a parent’s transform? If we update our demo so we can change the value of --packaged, we can see how the value affects the transforms. Try sliding the --packaged value between 1 and 0 and you’ll see exactly what I mean.

Now that we have a way to toggle the folding state of our template, we can start working on some motion. Our previous demo flips between the two states. We can make use of transition for that. The quickest way? Add a transition to the transform of every child in the .scene.

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

Multi-step transitions!

But we don’t fold the template all up in one go — in real life, there’s a sequence to it where we’d fold up one side and its flap first then move on to the next, and so on. Scoped custom properties are perfect for this.

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

Here we are saying that, for each transition, use a transition-delay of --step multiplied by --delay. The --delay value won’t change but each element can define which “step” it is in the sequence. And then we can be explicit 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 for 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?

That same technique is key for what we are going to for. We could even introduce an --initial-delay that adds a slight pause to everything for even more realism.

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

If we look back at our package, we can take this further and apply a “step” to all the elements that are going to transform. It’s quite verbose but it does the job. Alternatively, you could inline 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 feel very realistic.

Maybe we oughta flip the box, too

If I were folding up the box in real life, I’d likely flip the box up before folding in the top flaps. So how might we do that? Well, those with an eager eye might have noticed the .package__wrapper element. We are going to use this to slide the package. Then we’re going to rotate the package on the x-axis. This will create the impression of flipping the package onto its side.

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

Adjusting the --step declarations accordingly gives us something like this.

Unfolding the box

If you flip between the folded and not folded states, you’ll notice that the unfold doesn’t look right. The unfolding sequence should be the exact reverse of the folding sequence. We could flip the --step based on --packaged and the number of steps. Our latest step is 15. We can update our 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);
}

That is quite the mouthful of calc to reverse the transition-delay. But, it works! We must remind ourselves to keep that --no-of-steps value up to date though!

We do have another option. As we continue down the “pure CSS” route, we will eventually make use of the checkbox hack to toggling between the folding states. We could have two sets of defined “steps” where one set is active when our checkbox gets checked. It’s certainly a more verbose solution. But, it does give us more finite control.

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

Sizing and centering

Before we ditch the use of [dat.gui](https://github.com/dataarts/dat.gui) in our demo, let’s have a play with the size of our package. We want to check that our package remains centered while folding and flipping. In this demo, the package has a larger --height and the .scene has a dashed border.

We may as well tweak our 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 in the scene. It all comes down to preference though!

Adding in the checkbox hack

Now let’s get dat.gui out of the way and make this “pure” CSS. For this, we need to introduce a bunch of controls in the HTML. We are going to use a checkbox for folding and unfolding our package. Then we’re going to use a radio button to pick a 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 demo, we will hide the inputs and make use of the label elements. For now, though, let’s leave them all visible. The trick is to use the sibling combinator (~) when certain controls get :checked. We can then set custom property values on the .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 demo with that working!

Final polish

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

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 style the sizing options as rounded 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 tap anywhere to toggle between folding and unfolding our package. So our .open and .close labels will take up the full screen. Wondering why we have two labels? It’s a little trick. If we use a transition-delay and scale up the appropriate label, we can hide both labels while the package transitions. This is how we combat spam tapping (even though it won’t stop a user hitting the space bar on a 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 to both .open and .close. Neither label is visible during the transition.

We’ve got complete functionality! But, our package is a little underwhelming at the moment. Let’s add extra details to make things more “box”-like with things like parcel tape and packing labels.

Little details like this are only limited by our imagination! We can use our --packaged custom property to affect anything. For example, the .package__tape is transitioning the scaleY transform:

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

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

That’s it!

That’s how you make a pure CSS 3D package toggle. Are you going to drop this into your website? Unlikely! But, it’s fun to see how you might achieve these things with CSS. Custom properties are so powerful.

Why not get super festive and give the gift of CSS!

Stay Awesome! ʕ •ᴥ•ʔ