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.

  • 116 lines of CSS
  • 20 lines of HTML
  • No JavaScript
  • No dependencies
  • MIT licensed

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.

Key techniques

  • rotateX(60deg) 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 116 lines

.scene {
  perspective: 800px;
}

/* the floor is the stack's own plane, turned isometric; "up" is translateZ */
.stack {
  position: relative;
  display: grid;
  grid-template-columns: repeat(2, 38px);
  grid-auto-rows: 38px;
  transform-style: preserve-3d;
  transform: rotateX(58deg) rotateZ(45deg);
}

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

/* a column: lifts away once the cube is complete, one after the other */
.col {
  position: relative;
  margin: 2px;
  transform-style: preserve-3d;
  transform-origin: 50% 50% 34px; /* the middle of the two-block column */
  animation: stack-leave 5s linear infinite;
  animation-delay: calc(var(--c) * 0.275s - 1.6s);
}

/* a block: pops up in the air, falls, bounces, sits; bottom layer first */
.block {
  --z: calc(var(--l) * 34px); /* resting height */
  --color: #8b6cff;
  position: absolute;
  inset: 0;
  transform-style: preserve-3d;
  transform-origin: 50% 50% 17px;
  animation: stack-arrive 5s linear infinite;
  /* minus one loop: every block is already mid-cycle on load */
  animation-delay: calc(var(--i) * 0.275s - 5s);
}

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

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

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

.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) + 56px)) scale3d(0, 0, 0);
    animation-timing-function: ease-out;
  }
  3% {
    transform: translateZ(calc(var(--z) + 56px)) 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) + 5px)) 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) + 56px)) 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(72px) 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);
  }
}

Sass source 135 lines

$s: 34px; // block size
$cell: 38px; // block + gap
$time: 5s;
$step: 0.275s; // between one block and the next (5.5% of the loop)
$drop: 56px; // how high a block appears before it falls

// The floor is the root's own plane, turned into an isometric view; "up" is translateZ.
// Four columns stand on it (a 2×2 grid), each holding a bottom and a top block.
.d-blockstack {
  position: relative;
  display: grid;
  grid-template-columns: repeat(2, $cell);
  grid-auto-rows: $cell;
  transform-style: preserve-3d;
  transform: rotateX(58deg) rotateZ(45deg);

  // the floor plate, with the four places the blocks will land
  &::before {
    content: '';
    position: absolute;
    inset: -8px;
    border: 1px dashed var(--border-strong);
    border-radius: 10px;
    background:
      linear-gradient(90deg, transparent calc(50% - 1px), var(--border) 0 calc(50% + 1px), transparent 0),
      linear-gradient(0deg, transparent calc(50% - 1px), var(--border) 0 calc(50% + 1px), transparent 0),
      color-mix(in srgb, var(--accent) 10%, transparent);
    transform: translateZ(-1px);
  }

  // A column carries the "leave" animation: once the cube is complete, the columns lift away one
  // after the other (--c = 0…3), then come back invisibly, empty, before their blocks drop again.
  &__col {
    position: relative;
    margin: ($cell - $s) * 0.5;
    transform-style: preserve-3d;
    transform-origin: 50% 50% $s; // the middle of the two-block column
    animation: d-blockstack-leave $time linear infinite;
    animation-delay: calc(var(--c) * #{$step} - #{$time * 0.32});
  }

  // A block carries the "arrive" animation: pop into the air, fall, bounce, sit, then vanish once
  // its column has flown off. --i = 0…7 staggers it (bottom layer first, back corner first).
  // Delays are shifted back by a whole loop so every block is already mid-cycle on load.
  &__block {
    --z: calc(var(--l) * #{$s}); // resting height: bottom layer 0, top layer one block up
    --c1: var(--accent);
    position: absolute;
    inset: 0;
    transform-style: preserve-3d;
    transform-origin: 50% 50% ($s * 0.5);
    animation: d-blockstack-arrive $time linear infinite;
    animation-delay: calc(var(--i) * #{$step} - #{$time});

    &:nth-child(2) {
      --c1: var(--accent-2);
    }

    // only the three faces we can ever see: top, +x and +y
    i {
      position: absolute;
      inset: 0;
      border-radius: 2px;
    }

    i:nth-child(1) {
      background: color-mix(in srgb, var(--c1) 55%, #fff);
      transform: translateZ($s);
    }

    i:nth-child(2) {
      background: color-mix(in srgb, var(--c1) 65%, #000);
      transform-origin: 100% 50%;
      transform: rotateY(90deg);
    }

    i:nth-child(3) {
      background: var(--c1);
      transform-origin: 50% 100%;
      transform: rotateX(-90deg);
    }
  }
}

// Every stop keeps the same function list (translateZ, scale3d) so each value interpolates alone.
@keyframes d-blockstack-arrive {
  0% {
    transform: translateZ(calc(var(--z) + #{$drop})) scale3d(0, 0, 0);
    animation-timing-function: ease-out;
  }

  3% {
    transform: translateZ(calc(var(--z) + #{$drop})) 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) + 5px)) 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) + #{$drop})) scale3d(0, 0, 0);
  }
}

@keyframes d-blockstack-leave {
  0% {
    transform: translateZ(0) rotateZ(0deg) scale3d(1, 1, 1);
    animation-timing-function: ease-in;
  }

  7%,
  31.25% {
    transform: translateZ(72px) 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.