Pure CSS Shapes & solids #loop #shape

Unfolding cube in pure CSS

A cube’s cross-shaped net lies open on a floor, folds up into a cube that turns a full circle, and opens flat again. The lid hangs off a wall, so its hinge rides along with the wall’s: nested transforms compound. The camera moves in as it folds, so the cube is as big as the net.

  • 83 lines of CSS
  • 10 lines of HTML
  • No JavaScript
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. Lay the six faces out flat as the cross-shaped net. Each face sits next to the edge it shares with its parent and uses that edge as transform-origin.
  2. Folding is then one rotateX(±90deg) or rotateY(±90deg) per face. The angle lives in --fx / --fy, and one shared keyframe rule reads it, so every wall folds its own way.
  3. The lid is a child of the north wall, not of the base. Its hinge rides along as the wall stands up, and its 90° adds to the wall's: nested transforms compound.
  4. The loop starts, and holds for a moment, lying open flat on a floor seen from above, turned 160° so its long arm reaches towards you: the first frame is what a paused card shows, and at an angle, with the near squares larger than the far ones, it reads as a net lying in 3D rather than a flat plus sign. The lid folds along with its wall, so the two never stand up as one tall plank. Every timeline is mirror-symmetric, so the loop is seamless.
  5. The base turns a full circle between 8% and 92% of the loop (0%, 8% and 92%, 100% hold the same angle, 360° apart). It eases in and out, so most of the turn happens while the cube is shut: every side gets seen, and the net always opens the same way round.
  6. The camera is one more keyframe rule on the same 8s: it keeps the tilt and moves in (scale3d(1.45, …)) and down as the walls stand up, so the shut cube fills the frame as the open net does, and the middle of the drawing stays in the middle.
  7. Every length is a multiple of one base unit, --u, so the net is the same share of a gallery card, the editor and a recording canvas. The faces are coloured glass at 55% with a solid edge, so they read on a light stage as well as a dark one.

Key techniques

  • nested hinged faces
  • transform-origin on the shared edge
  • var() fold angle inside @keyframes
  • camera zoom on the fold’s own timeline

Copy-paste code

HTML 10 lines

<div class="scene">
  <div class="net">
    <div class="base">
      <i class="n"><i class="lid"></i></i>
      <i class="s"></i>
      <i class="e"></i>
      <i class="w"></i>
    </div>
  </div>
</div>

CSS 83 lines

.scene {
  /* one base unit: every length below is a multiple of it, so the net is the same share of a
     card, the editor, a full screen and a recording canvas */
  --u: 0.33vmin;
  perspective: calc(560 * var(--u));
}

/* the camera looks down at a floor the net lies on. It moves in as the walls fold up, so the
   closed cube is as big in the frame as the open net: the zoom runs on the fold's own timeline */
.net {
  transform-style: preserve-3d;
  animation: zoom 8s ease-in-out infinite;
}

/* the bottom face turns a full circle while the cube is up, so every side gets seen, and is
   back where it started before the net lies flat again. So the open net always lies the same
   way: turned 160deg, the lid's arm reaching towards you, larger than the far squares, so even
   a paused card shows a net lying on a floor and never a flat plus sign. The north wall then
   stands up at the front, where its height does not tower over the rest */
.base {
  --c: 139 108 255;
  position: relative;
  width: calc(60 * var(--u));
  height: calc(60 * var(--u));
  transform-style: preserve-3d;
  animation: turn 8s ease-in-out infinite;
}

.base,
.base i {
  /* coloured glass, dense enough to read on a light stage as well as a dark one */
  background: rgb(var(--c) / 0.55);
  /* inset shadows instead of a border: no layout offset, so hinges sit exactly on the edges */
  box-shadow: inset 0 0 0 calc(1.5 * var(--u)) rgb(var(--c)), inset 0 0 calc(18 * var(--u)) rgb(var(--c) / 0.6);
}

.base i {
  position: absolute;
  width: calc(60 * var(--u));
  height: calc(60 * var(--u));
  transform-style: preserve-3d; /* the north wall carries the lid */
  animation: fold 8s ease-in-out infinite;
}

/* each face lies next to its shared edge and hinges on it */
.n   { --c: 255 77 157;  --fx: -90deg; left: 0; bottom: 100%; transform-origin: bottom; }
.s   { --c: 46 230 214;  --fx: 90deg;  left: 0; top: 100%;    transform-origin: top; }
.e   { --c: 255 181 71;  --fy: -90deg; top: 0;  left: 100%;   transform-origin: left; }
.w   { --c: 255 181 71;  --fy: 90deg;  top: 0;  right: 100%;  transform-origin: right; }
.lid { --c: 139 108 255; --fx: -90deg; left: 0; bottom: 100%; transform-origin: bottom; }

.base .lid {
  animation-name: lid;
}

@keyframes turn {
  0%, 8%    { transform: rotateZ(160deg); }
  92%, 100% { transform: rotateZ(520deg); }
}

/* the camera: tilted down at the floor all the time. It follows the middle of what is drawn
   (the near arm of the open net hangs low, the cube stands up above the floor) and moves in
   once the walls are up, so the shut cube fills the frame as the open net does */
@keyframes zoom {
  0%, 6%, 94%, 100% { transform: translateY(calc(-31 * var(--u))) rotateX(56deg) scale3d(1, 1, 1); }
  16%, 84%          { transform: translateY(calc(12 * var(--u))) rotateX(56deg) scale3d(1.12, 1.12, 1.12); }
  28%, 72%          { transform: translateY(calc(33 * var(--u))) rotateX(56deg) scale3d(1.45, 1.45, 1.45); }
}

/* The loop starts lying open flat, the net itself: that is the first frame, so it is what a
   paused card shows. walls: flat, fold up, stay closed while the cube turns, unfold */
@keyframes fold {
  0%, 6%, 94%, 100% { transform: rotateX(0deg) rotateY(0deg); }
  28%, 72%          { transform: rotateX(var(--fx, 0deg)) rotateY(var(--fy, 0deg)); }
}

/* lid: folds at the same time as the wall that carries it, so the wall and the lid never stand
   up together as one tall plank. It can never pass through a wall: it is exactly as wide as the
   base, and the side walls lean outside that width until they are upright */
@keyframes lid {
  0%, 6%, 94%, 100% { transform: rotateX(0deg) rotateY(0deg); }
  26%, 74%          { transform: rotateX(var(--fx, 0deg)) rotateY(var(--fy, 0deg)); }
}
Standalone snippet · plain CSS, no build step, no dependencies.