How to Make a Pure CSS 3D Package Toggle
Tags: css, webdev, tutorial, beginners
Published: 2022-02-17 01:21:31

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.

{% codepen https://codepen.io/jh3y/pen/XWaGEgO %}

## 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](https://css-tricks.com/css-in-3d-learning-to-think-in-cubes-instead-of-boxes/) 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.

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

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

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:

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

## 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.](https://i0.wp.com/css-tricks.com/wp-content/uploads/2021/12/box-flaps-nested.jpg?resize=1000%2C269&ssl=1)

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

{% codepen https://codepen.io/jh3y/pen/RwLxmwK %}


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:

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

Not nested:

```html
<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.

{% codepen https://codepen.io/jh3y/pen/NWaWewx %}


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.

```css
.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 <a href="https://youtu.be/kZOJCVvyF-4">2016 CSS ConfAsia talk</a> (starting at <a href="https://www.youtube.com/watch?v=kZOJCVvyF-4&amp;t=1278s">52:44</a>). Using custom properties as “data” instead of “values,” we are free to do what we want with them using <code>calc()</code>. 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 <code>--root</code>, 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.

```css
.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.

```css
.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.

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

{% codepen https://codepen.io/jh3y/pen/VwMaMVE %}

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

```css
.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.

```css
.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.

{% codepen https://codepen.io/jh3y/pen/oNGLXjW %}


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.

{% codepen https://codepen.io/jh3y/pen/xxXOGrq %}


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

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

{% codepen https://codepen.io/jh3y/pen/ZEXOGmp %}

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

```css
.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.

```css
.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?

{% codepen https://codepen.io/jh3y/pen/LYzeoJW %}


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.

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

{% codepen https://codepen.io/jh3y/pen/GRMqpqb %}


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.

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

{% codepen https://codepen.io/jh3y/pen/jOGrbzm %}


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.

```css
.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.

{% codepen https://codepen.io/jh3y/pen/gOGMPmm %}

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

```css
.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!

{% codepen https://codepen.io/jh3y/pen/zYEBqPo %}


We do have another option. As we continue down the “pure CSS” route, we will eventually make use of the [checkbox hack](https://twitter.com/jh3yy/status/1380288957930475520?s=20) 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.

```css
/* 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.

{% codepen https://codepen.io/jh3y/pen/JjrKeRL %}


We may as well tweak our `transform` to better center the package while we’re at it:

```css
/* 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!

{% codepen https://codepen.io/jh3y/pen/dyVXQqr %}

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

```html
<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`.

```css
#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!

{% codepen https://codepen.io/jh3y/pen/xxXOmeG %}

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

```css
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:

```css
.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).

```css
.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.

{% codepen https://codepen.io/jh3y/pen/abLZXLm %}


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.

{% codepen https://codepen.io/jh3y/pen/RwLRdBX %}


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:

```css
.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!

{% codepen https://codepen.io/jh3y/pen/jOGroXN %}


**Stay Awesome! ʕ •ᴥ•ʔ**