Pure CSS Loaders & patterns #loop #loader #shape #isometric

Stacking blocks loader in pure CSS

Eight blocks pop into the air and drop one by one onto an isometric floor until they form a 2×2×2 cube, then the cube comes apart column by column and floats away, and it all starts again.

  • 125 lines of CSS
  • 20 lines of HTML
  • No JavaScript
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. rotateX(58deg) rotateZ(45deg) turns a flat 2×2 grid into an isometric floor; from then on "up" is simply translateZ. A block only needs the three faces this camera can ever see: the top, the +x side and the +y side.
  2. Blocks must arrive one by one but leave in whole columns (a bottom block cannot fly off from under the one on top of it). So the timing is split over two nested animations: the block drops in, and its column (the parent, holding a bottom and a top block) lifts away.
  3. Both are shared keyframes staggered with animation-delay from --i (block 0…7) and --c (column 0…3), so eight blocks need only one "arrive" and one "leave" animation.
  4. The invisible resets are timed to happen while something else hides them: a block snaps back up while its column is gone, and the column snaps back while both its blocks are scaled to 0 — so the loop never shows a jump.
  5. Every keyframe keeps the same function list (translateZ … scale3d), and scale3d(0, 0, 0) rather than scale(0), because a 2D scale would leave a block's height standing as a line.
  6. Every length is a multiple of one base unit, --u, tied to the canvas: a block is 34 units, and it drops in from 20 above its place, low enough that a drop onto the top layer stays inside the canvas, so the stack is the same share of a gallery card, the editor and a recording canvas.

Key techniques

  • rotateX(58deg) rotateZ(45deg) = isometric floor
  • block = 3 visible faces only
  • two nested animations: block drops, column leaves
  • shared keyframes + delays from --i

Copy-paste code

HTML 20 lines

<div class="scene">
  <div class="stack" role="img" aria-label="Loading">
    <div class="col" style="--c:0">
      <div class="block" style="--i:0; --l:0"><i></i><i></i><i></i></div>
      <div class="block" style="--i:4; --l:1"><i></i><i></i><i></i></div>
    </div>
    <div class="col" style="--c:1">
      <div class="block" style="--i:1; --l:0"><i></i><i></i><i></i></div>
      <div class="block" style="--i:5; --l:1"><i></i><i></i><i></i></div>
    </div>
    <div class="col" style="--c:2">
      <div class="block" style="--i:2; --l:0"><i></i><i></i><i></i></div>
      <div class="block" style="--i:6; --l:1"><i></i><i></i><i></i></div>
    </div>
    <div class="col" style="--c:3">
      <div class="block" style="--i:3; --l:0"><i></i><i></i><i></i></div>
      <div class="block" style="--i:7; --l:1"><i></i><i></i><i></i></div>
    </div>
  </div>
</div>

CSS 125 lines

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

/* the floor is the stack's own plane, turned isometric; "up" is translateZ.
   The blocks and the columns all fly upwards and nothing ever goes below the plate, so the
   drawing sits well above its own layout box. translateY pushes the plate back down by that
   much, which centres what is actually drawn rather than what is laid out: the solid blocks,
   over the whole loop, since the eye places the stack by them and not by the faint plate. */
.stack {
  position: relative;
  display: grid;
  grid-template-columns: repeat(2, calc(38 * var(--u)));
  grid-auto-rows: calc(38 * var(--u));
  transform-style: preserve-3d;
  transform: translateY(calc(30 * var(--u))) rotateX(58deg) rotateZ(45deg);
}

/* the floor plate with the four landing places */
.stack::before {
  content: '';
  position: absolute;
  inset: calc(-8 * var(--u));
  border: calc(1 * var(--u)) dashed rgb(140 150 220 / 0.34);
  border-radius: calc(10 * var(--u));
  background:
    linear-gradient(90deg, transparent calc(50% - 1 * var(--u)), rgb(140 150 220 / 0.16) 0 calc(50% + 1 * var(--u)), transparent 0),
    linear-gradient(0deg, transparent calc(50% - 1 * var(--u)), rgb(140 150 220 / 0.16) 0 calc(50% + 1 * var(--u)), transparent 0),
    rgb(139 108 255 / 0.1);
  transform: translateZ(calc(-1 * var(--u)));
}

/* a column: lifts away once the cube is complete, one after the other */
.col {
  position: relative;
  margin: calc(2 * var(--u));
  transform-style: preserve-3d;
  transform-origin: 50% 50% calc(34 * var(--u)); /* the middle of the two-block column */
  animation: stack-leave 5s linear infinite;
  /* 3s further on than the blocks' own start, like them (see .block) */
  animation-delay: calc(var(--c) * 0.275s - 4.6s);
}

/* a block: pops up in the air, falls, bounces, sits; bottom layer first */
.block {
  --z: calc(var(--l) * 34 * var(--u)); /* resting height */
  --color: #8b6cff;
  position: absolute;
  inset: 0;
  transform-style: preserve-3d;
  transform-origin: 50% 50% calc(17 * var(--u));
  animation: stack-arrive 5s linear infinite;
  /* 3s into the loop on load: the cube is built and has not begun to come apart, so the stack
     looks finished when it is paused, and every block is already mid-cycle */
  animation-delay: calc(var(--i) * 0.275s - 3s);
}

.block:nth-child(2) { --color: #2ee6d6; }

/* only the three faces we can ever see */
.block i {
  position: absolute;
  inset: 0;
  border-radius: calc(2 * var(--u));
}

.block i:nth-child(1) { /* top */
  background: color-mix(in srgb, var(--color) 55%, #fff);
  transform: translateZ(calc(34 * var(--u)));
}

.block i:nth-child(2) { /* +x side, stood up on its right edge */
  background: color-mix(in srgb, var(--color) 65%, #000);
  transform-origin: 100% 50%;
  transform: rotateY(90deg);
}

.block i:nth-child(3) { /* +y side, stood up on its bottom edge */
  background: var(--color);
  transform-origin: 50% 100%;
  transform: rotateX(-90deg);
}

@keyframes stack-arrive {
  0% {
    transform: translateZ(calc(var(--z) + 20 * var(--u))) scale3d(0, 0, 0);
    animation-timing-function: ease-out;
  }
  3% {
    transform: translateZ(calc(var(--z) + 20 * var(--u))) scale3d(1, 1, 1);
    animation-timing-function: ease-in; /* gravity */
  }
  10% {
    transform: translateZ(var(--z)) scale3d(1, 1, 1);
    animation-timing-function: ease-out;
  }
  12.5% {
    transform: translateZ(calc(var(--z) + 5 * var(--u))) scale3d(1, 1, 1);
    animation-timing-function: ease-in;
  }
  15%, 76.5% {
    transform: translateZ(var(--z)) scale3d(1, 1, 1);
  }
  /* its column is gone by now: reset out of sight */
  76.6%, 100% {
    transform: translateZ(calc(var(--z) + 20 * var(--u))) scale3d(0, 0, 0);
  }
}

@keyframes stack-leave {
  0% {
    transform: translateZ(0) rotateZ(0deg) scale3d(1, 1, 1);
    animation-timing-function: ease-in;
  }
  7%, 31.25% {
    transform: translateZ(calc(56 * var(--u))) rotateZ(90deg) scale3d(0, 0, 0);
  }
  /* both its blocks have reset by now: come back, still empty */
  31.35%, 100% {
    transform: translateZ(0) rotateZ(0deg) scale3d(1, 1, 1);
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.