Pure CSS Shapes & solids #loop #shape

Unfolding cube in pure CSS

A cube opens into its flat cross-shaped net and folds back up. The lid hangs off a wall, so its hinge rides along with the wall’s: nested transforms compound.

  • 61 lines of CSS
  • 10 lines of HTML
  • No JavaScript
  • No dependencies
  • MIT licensed

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 lid has its own, wider keyframes: it opens first and closes last, so it never folds through a wall. Both timelines are mirror-symmetric, so the loop is seamless.

Key techniques

  • nested hinged faces
  • transform-origin on the shared edge
  • var() fold angle inside @keyframes
  • staggered keyframe timing

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

.scene {
  perspective: 800px;
}

/* static camera looking down at the floor */
.net {
  transform-style: preserve-3d;
  transform: translateY(20px) rotateX(58deg);
}

/* the bottom face only turns, so every side gets seen */
.base {
  --c: 139 108 255;
  position: relative;
  width: 60px;
  height: 60px;
  transform-style: preserve-3d;
  animation: turn 24s linear infinite;
}

.base,
.base i {
  background: rgb(var(--c) / 0.28);
  /* inset shadows instead of a border: no layout offset, so hinges sit exactly on the edges */
  box-shadow: inset 0 0 0 1px rgb(var(--c) / 0.75), inset 0 0 24px rgb(var(--c) / 0.3);
}

.base i {
  position: absolute;
  width: 60px;
  height: 60px;
  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 {
  to { transform: rotateZ(360deg); }
}

/* walls: closed, wait for the lid, open, rest flat, close again */
@keyframes fold {
  0%, 22%, 78%, 100% { transform: rotateX(var(--fx, 0deg)) rotateY(var(--fy, 0deg)); }
  44%, 56%           { transform: rotateX(0deg) rotateY(0deg); }
}

/* lid: first to open, last to close */
@keyframes lid {
  0%, 4%, 96%, 100% { transform: rotateX(var(--fx, 0deg)) rotateY(var(--fy, 0deg)); }
  26%, 74%          { transform: rotateX(0deg) rotateY(0deg); }
}

Sass source 113 lines

@use '../mixins' as *;

$s: 40px;

// static camera: look down at the floor the net lies on
.d-net {
  transform-style: preserve-3d;
  transform: translateY(12px) rotateX(58deg);

  // The bottom face never moves; it only turns slowly so every side gets seen.
  &__base {
    position: relative;
    width: $s;
    height: $s;
    transform-style: preserve-3d;
    animation: d-net-turn 24s linear infinite;
    @include face(var(--accent), 30%);
  }

  // Every other face lies flat NEXT to the edge it shares with its parent and hinges on that
  // edge. --fx / --fy hold the folded angle; the keyframes swing between it and 0 (flat).
  // :where() keeps this rule at the specificity of a single class, so the per-face rules below
  // (also one class, later in the file) can override --fx / --fy and the lid's animation-name.
  :where(i) {
    --fx: 0deg;
    --fy: 0deg;
    position: absolute;
    width: $s;
    height: $s;
    transform-style: preserve-3d;
    animation: d-net-fold 8s ease-in-out infinite;
  }

  // Faces are positioned against the parent's padding box, which sits 1px inside its border:
  // -1px / calc(100% + 1px) put every hinge exactly on the parent's outer edge.
  &__n {
    --fx: -90deg;
    left: -1px;
    bottom: calc(100% + 1px);
    transform-origin: 50% 100%;
    @include face(var(--hot), 28%);
  }

  &__s {
    --fx: 90deg;
    left: -1px;
    top: calc(100% + 1px);
    transform-origin: 50% 0;
    @include face(var(--accent-2), 28%);
  }

  &__e {
    --fy: -90deg;
    top: -1px;
    left: calc(100% + 1px);
    transform-origin: 0 50%;
    @include face(var(--warm), 28%);
  }

  &__w {
    --fy: 90deg;
    top: -1px;
    right: calc(100% + 1px);
    transform-origin: 100% 50%;
    @include face(var(--warm), 28%);
  }

  // The lid is a CHILD of the north wall, so its 90deg adds to the wall's 90deg: hinges compound.
  // It gets its own, wider keyframes: first to open, last to close.
  &__lid {
    --fx: -90deg;
    left: -1px;
    bottom: calc(100% + 1px);
    transform-origin: 50% 100%;
    animation-name: d-net-lid;
    @include face(var(--accent), 30%);
  }
}

@keyframes d-net-turn {
  to {
    transform: rotateZ(360deg);
  }
}

// walls: closed, wait for the lid, open, rest flat, close again. Mirror-symmetric, so it loops.
@keyframes d-net-fold {
  0%,
  22%,
  78%,
  100% {
    transform: rotateX(var(--fx)) rotateY(var(--fy));
  }

  44%,
  56% {
    transform: rotateX(0deg) rotateY(0deg);
  }
}

@keyframes d-net-lid {
  0%,
  4%,
  96%,
  100% {
    transform: rotateX(var(--fx)) rotateY(var(--fy));
  }

  26%,
  74% {
    transform: rotateX(0deg) rotateY(0deg);
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.