Building a Pure CSS 3D Box Toggle

Cardboard boxes ship flat and get folded into shape. Recreating that fold/unfold motion in CSS makes for an engaging front-end challenge—one that relies on nested transforms, scoped custom properties, and multi-step transitions. Here’s how to build a package that toggles between flat and assembled states with zero JavaScript.

Start With a Template, Not a Cuboid

When working with 3D in CSS, the instinct might be to construct a cuboid and animate from there. That approach fails here because a cuboid has no flat, unfolded state. Instead, build the markup as a 2D template that folds into 3D.

Using Pug keeps the markup manageable. Two mixins generate the structure: one for flaps, one for the sides of the box. The side mixin accepts a block for nested children, which is key for the folding behavior later.

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

Why Nesting Matters

Nesting sides inside one another is what makes the folding sequence possible. A child side inherits its parent’s transform before applying its own. If all sides were siblings, each would need its own absolute transform path—far harder to coordinate.

Each side gets a transform-origin at its bottom-right corner. When the parent rotates 90 degrees, the child rotates relative to that new orientation. Trying this with flat, non-nested elements produces chaotic results because each element rotates around the same global origin.

Set Up Sizing With Custom Properties

Sizing uses three custom properties: --height, --width, and --depth. The defaults are unitless (20) so that calc() can apply them to different units later—pixels, percentages, whatever the layout requires. This decouples the geometry logic from any specific unit system.

The template styling positions each side relative to a “main” side. Flaps are slightly smaller than their parent side to create visible seams, and flaps on certain sides sit lower to avoid z-index conflicts when folded.

Folding With a Single Custom Property

All folding logic keys off one custom property: --packaged. When set to 1, the template folds: each non-main side rotates on its transform-origin. When set to 0, everything returns flat.

A single rule can target 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; }

Because sides are nested, a side’s transform applies on top of its parent’s, so the fold sequence cascades naturally.

Multi-Step Transitions for Realistic Motion

A real box doesn’t fold all at once—one flap goes in, then another, then the side flips. CSS transitions can replicate this using scoped custom properties. Each element gets a --step value, and the transition delay is calculated like this:

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

Each transition uses a transition-delay of --step multiplied by --delay. Changing an element’s --step changes its place in the sequence without touching the shared timing values.

This technique is verbose—each transforming element needs its step defined—but it offers precise control over the order of operations.

Flipping the Box for a Better Illusion

Folding up a real box involves flipping it onto its side before tucking flaps. The .package__wrapper element handles this: it slides and rotates the entire package on the x-axis, creating the impression of flipping it over. Assigning --step values to the wrapper in the sequence makes the flip happen at the right moment.

Reversing the Sequence for Unfolding

Unfolding must reverse the exact fold order. With the latest step at 15, the transition-delay needs to invert based on --packaged:

.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 calc() expression reverses the sequence when the package opens. The --no-of-steps value must stay in sync with the highest step used anywhere in the CSS—easy to forget each time the sequence grows.

Pure CSS Toggling With the Checkbox Hack

Replacing the debug panel with pure CSS requires a set of hidden inputs. A checkbox toggles between folded and unfolded states, while radio buttons choose package size. Visible controls are replaced with styled labels.

The sibling combinator (~) responds to :checked inputs by setting custom property values on the .scene element. Styling the labels as full-screen tap targets makes the whole package clickable.

Polishing the Interaction

Spam clicking can interrupt transitions, so the overlay labels use a clever delay trick: .open and .close labels scale up to hide during the transition, then the relevant one scales down when the animation completes. Keyboard space-bar toggling remains available.

Final visual touches—parcel tape, packing labels—animate independently using the same --packaged property. Things like tape use scaleY transforms that respond to the package state. Every new animated element adds another step to the sequence, so both the --step definitions and --no-of-steps must be updated together.

Worth the Effort?

This isn’t the kind of component you ship to production, but it demonstrates how far CSS custom properties can go. The entire interaction—multi-step folding, coordinated delays, full-scene rotation—lives in stylesheets. No JavaScript event handlers, no animation libraries, just well-scoped custom properties and calculated transitions.