Pure CSS Loaders & patterns #loop #loader #puzzle #hanoi

Tower of Hanoi in pure CSS

A loader that solves the Tower of Hanoi: three disks lift, cross and drop in the 7-move legal order from one peg to another. Then the round board turns a third of a turn, so the finished peg stands where the first one did and the loop starts again with no jump.

  • 165 lines of CSS
  • 50 lines of HTML
  • No JavaScript
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. Three disks go from peg A to peg C in the only 7-move legal order (never a bigger disk on a smaller one). Each disk has one @keyframes of translate3d stops, four per move: on its peg, lifted clear of the pegs, over the new peg, dropped onto the stack. Between its moves a disk has no stops, so it simply waits. The stops were worked out once from the move list and written in as plain CSS.
  2. To loop without solving it backwards, the pegs stand a third of a turn apart on a round board. When the stack reaches C, the board turns rotateY(-120deg) and C lands exactly where A stood, so the last frame is the first. The base slab and its rim are round and never move; only the turntable on top turns, with its three peg sockets.
  3. A disk is a 12-sided prism with real thickness, but the camera never moves, so only its front seven strips are drawn: each rotateY(k × 30deg) translateZ(r), with a darkness from its direction baked in. The cap is one square cut to a 12-gon with clip-path, its dark centre the hole the peg comes up through.
  4. So the light stays put, everything that rides the turning board turns back by the same 120deg on its own axis: the disks in their last keyframe, the pegs in an animation of their own. A peg is one flat bar, a billboard that always faces you, which a thin cylinder looks like from any side.
  5. Every length is a multiple of one base unit, --u; the paused first moment is the full stack on A, at rest.

Key techniques

  • keyframes worked out from the move list
  • a third of a turn makes the loop seamless
  • front-half prisms for disks
  • billboard pegs that turn back

Copy-paste code

HTML 50 lines

<div class="scene">
  <div class="view" role="img" aria-label="Loading: the Tower of Hanoi solving itself">
    <div class="base">
      <i style="--k:-3; --d:0.22"></i>
      <i style="--k:-2; --d:0.04"></i>
      <i style="--k:-1; --d:0"></i>
      <i style="--k:0; --d:0.12"></i>
      <i style="--k:1; --d:0.34"></i>
      <i style="--k:2; --d:0.52"></i>
      <i style="--k:3; --d:0.6"></i>
      <b></b>
    </div>
    <div class="board">
      <div class="turntable"></div>
      <div class="peg" style="--x:-52; --z:30"></div>
      <div class="peg" style="--x:0; --z:-60"></div>
      <div class="peg" style="--x:52; --z:30"></div>
      <div class="disk d3">
        <i style="--k:-3; --d:0.22"></i>
        <i style="--k:-2; --d:0.04"></i>
        <i style="--k:-1; --d:0"></i>
        <i style="--k:0; --d:0.12"></i>
        <i style="--k:1; --d:0.34"></i>
        <i style="--k:2; --d:0.52"></i>
        <i style="--k:3; --d:0.6"></i>
        <b></b>
      </div>
      <div class="disk d2">
        <i style="--k:-3; --d:0.22"></i>
        <i style="--k:-2; --d:0.04"></i>
        <i style="--k:-1; --d:0"></i>
        <i style="--k:0; --d:0.12"></i>
        <i style="--k:1; --d:0.34"></i>
        <i style="--k:2; --d:0.52"></i>
        <i style="--k:3; --d:0.6"></i>
        <b></b>
      </div>
      <div class="disk d1">
        <i style="--k:-3; --d:0.22"></i>
        <i style="--k:-2; --d:0.04"></i>
        <i style="--k:-1; --d:0"></i>
        <i style="--k:0; --d:0.12"></i>
        <i style="--k:1; --d:0.34"></i>
        <i style="--k:2; --d:0.52"></i>
        <i style="--k:3; --d:0.6"></i>
        <b></b>
      </div>
    </div>
  </div>
</div>

CSS 165 lines

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

/* the camera looks down on the board; its origin is the middle of the board's top. It sits a
   little low, since the pegs and the lifted disks rise above the board and nothing goes below */
.view {
  position: relative;
  transform-style: preserve-3d;
  transform: translateY(calc(8 * var(--u))) rotateX(-30deg);
}
.view * {
  position: absolute;
  transform-style: preserve-3d;
}

/* ---- the base: a 12-sided slab that never moves ---- */
.base {
  --r: 108;
  --c: #3b4078;
}
/* a side strip: k × 30deg round, pushed out to the rim, shaded by the way it faces */
.base > i,
.disk > i {
  left: calc((var(--r) * -0.26795 - 0.5) * var(--u));
  width: calc((var(--r) * 0.5359 + 1) * var(--u)); /* 2r × tan 15deg, and a unit over the seam */
  background:
    linear-gradient(rgb(255 255 255 / 0.35) 0 calc(1 * var(--u)), transparent 0),
    linear-gradient(rgb(13 10 38 / var(--d)), rgb(13 10 38 / var(--d))),
    var(--c);
  backface-visibility: hidden;
  transform: rotateY(calc(var(--k) * 30deg)) translateZ(calc(var(--r) * var(--u)));
}
.base > i {
  top: 0;
  height: calc(16 * var(--u));
  background:
    linear-gradient(rgb(255 255 255 / 0.25) 0 calc(1 * var(--u)), transparent 0),
    linear-gradient(rgb(13 10 38 / var(--d)), rgb(13 10 38 / var(--d))),
    linear-gradient(var(--c), #262a52);
}
/* a cap: a square cut to a 12-gon (2 / cos 15deg = 2.0706 times the strips' distance across) */
.base > b,
.disk > b {
  left: calc(var(--r) * -1.0353 * var(--u));
  top: calc(var(--r) * -1.0353 * var(--u));
  width: calc(var(--r) * 2.0706 * var(--u));
  height: calc(var(--r) * 2.0706 * var(--u));
  clip-path: polygon(98.3% 62.94%, 85.36% 85.36%, 62.94% 98.3%, 37.06% 98.3%, 14.64% 85.36%, 1.7% 62.94%, 1.7% 37.06%, 14.64% 14.64%, 37.06% 1.7%, 62.94% 1.7%, 85.36% 14.64%, 98.3% 37.06%);
}
.base > b {
  background: #4a5094;
  transform: rotateX(90deg);
}

/* ---- the board: everything on it turns a third of a turn at the end of each solve ---- */
.board {
  animation: hanoi-board 7s linear infinite;
}

/* the turntable, a hair above the base's top, with a socket under each peg. It is the same after
   a third of a turn, so the loop shows no jump */
.turntable {
  left: calc(-100 * var(--u));
  top: calc(-100 * var(--u));
  width: calc(200 * var(--u));
  height: calc(200 * var(--u));
  border-radius: 50%;
  background:
    radial-gradient(circle at 24% 65%, #191b3b 0 calc(5 * var(--u)), #8c92d6 0 calc(7 * var(--u)), transparent 0 calc(12 * var(--u))),
    radial-gradient(circle at 50% 20%, #191b3b 0 calc(5 * var(--u)), #8c92d6 0 calc(7 * var(--u)), transparent 0 calc(12 * var(--u))),
    radial-gradient(circle at 76% 65%, #191b3b 0 calc(5 * var(--u)), #8c92d6 0 calc(7 * var(--u)), transparent 0 calc(12 * var(--u))),
    radial-gradient(circle, #6c72c0 0 calc(10 * var(--u)), #585ea8 calc(30 * var(--u)), #4c5298 calc(100 * var(--u)));
  box-shadow: inset 0 0 0 calc(1.5 * var(--u)) rgb(255 255 255 / 0.18);
  transform: translateY(calc(-0.5 * var(--u))) rotateX(90deg);
}

/* a peg: one flat bar that always faces you, which is what a thin rod looks like from any side.
   It rides the board, so it turns back as the board turns */
.peg {
  left: calc(-4 * var(--u));
  top: calc(-63 * var(--u));
  width: calc(8 * var(--u));
  height: calc(63 * var(--u));
  border-radius: calc(4 * var(--u)) calc(4 * var(--u)) 0 0;
  background: linear-gradient(90deg, #9aa0d8, #f4f3ff 35%, #d2d0ee 60%, #6d7099);
  transform: translate3d(calc(var(--x) * var(--u)), 0, calc(var(--z) * var(--u))) rotateY(0deg);
  animation: hanoi-peg 7s linear infinite;
}

/* ---- the disks: seven front strips and a cap with the hole ---- */
.disk {
  --t: calc(16 * var(--u)); /* thickness */
}
.disk > i {
  top: calc(-1 * var(--t));
  height: var(--t);
}
.disk > b {
  background: radial-gradient(circle, #191b3b 0 calc(5.5 * var(--u)), color-mix(in srgb, var(--c) 70%, #fff) calc(6.5 * var(--u)), color-mix(in srgb, var(--c) 85%, #fff));
  transform: translateY(calc(-1 * var(--t))) rotateX(90deg);
}
.d3 { --r: 42; --c: #8b6cff; animation: hanoi-disk3 7s linear infinite; }
.d2 { --r: 32; --c: #2ee6d6; animation: hanoi-disk2 7s linear infinite; }
.d1 { --r: 22; --c: #ff4d9d; animation: hanoi-disk1 7s linear infinite; }

/* a rest, seven moves, then a third of a turn: C comes round to where A was */
@keyframes hanoi-board {
  0%, 80% { transform: rotateY(0deg); animation-timing-function: ease-in-out; }
  100% { transform: rotateY(-120deg); }
}
@keyframes hanoi-peg {
  0%, 80% { transform: translate3d(calc(var(--x) * var(--u)), 0, calc(var(--z) * var(--u))) rotateY(0deg); animation-timing-function: ease-in-out; }
  100% { transform: translate3d(calc(var(--x) * var(--u)), 0, calc(var(--z) * var(--u))) rotateY(120deg); }
}

/* each disk: on its peg, lifted clear, across, dropped; worked out from the 7 moves */
@keyframes hanoi-disk1 {
  0% { transform: translate3d(calc(-52 * var(--u)), calc(-32 * var(--u)), calc(30 * var(--u))) rotateY(0deg); }
  10% { transform: translate3d(calc(-52 * var(--u)), calc(-32 * var(--u)), calc(30 * var(--u))) rotateY(0deg); animation-timing-function: cubic-bezier(0.3, 0, 0.3, 1); } /* lift */
  13% { transform: translate3d(calc(-52 * var(--u)), calc(-70 * var(--u)), calc(30 * var(--u))) rotateY(0deg); animation-timing-function: ease-in-out; } /* across */
  17% { transform: translate3d(calc(52 * var(--u)), calc(-70 * var(--u)), calc(30 * var(--u))) rotateY(0deg); animation-timing-function: cubic-bezier(0.5, 0, 0.75, 0.4); } /* drop */
  20% { transform: translate3d(calc(52 * var(--u)), calc(0 * var(--u)), calc(30 * var(--u))) rotateY(0deg); }
  30% { transform: translate3d(calc(52 * var(--u)), calc(0 * var(--u)), calc(30 * var(--u))) rotateY(0deg); animation-timing-function: cubic-bezier(0.3, 0, 0.3, 1); } /* lift */
  33% { transform: translate3d(calc(52 * var(--u)), calc(-70 * var(--u)), calc(30 * var(--u))) rotateY(0deg); animation-timing-function: ease-in-out; } /* across */
  37% { transform: translate3d(calc(0 * var(--u)), calc(-70 * var(--u)), calc(-60 * var(--u))) rotateY(0deg); animation-timing-function: cubic-bezier(0.5, 0, 0.75, 0.4); } /* drop */
  40% { transform: translate3d(calc(0 * var(--u)), calc(-16 * var(--u)), calc(-60 * var(--u))) rotateY(0deg); }
  50% { transform: translate3d(calc(0 * var(--u)), calc(-16 * var(--u)), calc(-60 * var(--u))) rotateY(0deg); animation-timing-function: cubic-bezier(0.3, 0, 0.3, 1); } /* lift */
  53% { transform: translate3d(calc(0 * var(--u)), calc(-70 * var(--u)), calc(-60 * var(--u))) rotateY(0deg); animation-timing-function: ease-in-out; } /* across */
  57% { transform: translate3d(calc(-52 * var(--u)), calc(-70 * var(--u)), calc(30 * var(--u))) rotateY(0deg); animation-timing-function: cubic-bezier(0.5, 0, 0.75, 0.4); } /* drop */
  60% { transform: translate3d(calc(-52 * var(--u)), calc(0 * var(--u)), calc(30 * var(--u))) rotateY(0deg); }
  70% { transform: translate3d(calc(-52 * var(--u)), calc(0 * var(--u)), calc(30 * var(--u))) rotateY(0deg); animation-timing-function: cubic-bezier(0.3, 0, 0.3, 1); } /* lift */
  73% { transform: translate3d(calc(-52 * var(--u)), calc(-70 * var(--u)), calc(30 * var(--u))) rotateY(0deg); animation-timing-function: ease-in-out; } /* across */
  77% { transform: translate3d(calc(52 * var(--u)), calc(-70 * var(--u)), calc(30 * var(--u))) rotateY(0deg); animation-timing-function: cubic-bezier(0.5, 0, 0.75, 0.4); } /* drop */
  80% { transform: translate3d(calc(52 * var(--u)), calc(-32 * var(--u)), calc(30 * var(--u))) rotateY(0deg); }
  80% { transform: translate3d(calc(52 * var(--u)), calc(-32 * var(--u)), calc(30 * var(--u))) rotateY(0deg); animation-timing-function: ease-in-out; }
  100% { transform: translate3d(calc(52 * var(--u)), calc(-32 * var(--u)), calc(30 * var(--u))) rotateY(120deg); }
}

@keyframes hanoi-disk2 {
  0% { transform: translate3d(calc(-52 * var(--u)), calc(-16 * var(--u)), calc(30 * var(--u))) rotateY(0deg); }
  20% { transform: translate3d(calc(-52 * var(--u)), calc(-16 * var(--u)), calc(30 * var(--u))) rotateY(0deg); animation-timing-function: cubic-bezier(0.3, 0, 0.3, 1); } /* lift */
  23% { transform: translate3d(calc(-52 * var(--u)), calc(-70 * var(--u)), calc(30 * var(--u))) rotateY(0deg); animation-timing-function: ease-in-out; } /* across */
  27% { transform: translate3d(calc(0 * var(--u)), calc(-70 * var(--u)), calc(-60 * var(--u))) rotateY(0deg); animation-timing-function: cubic-bezier(0.5, 0, 0.75, 0.4); } /* drop */
  30% { transform: translate3d(calc(0 * var(--u)), calc(0 * var(--u)), calc(-60 * var(--u))) rotateY(0deg); }
  60% { transform: translate3d(calc(0 * var(--u)), calc(0 * var(--u)), calc(-60 * var(--u))) rotateY(0deg); animation-timing-function: cubic-bezier(0.3, 0, 0.3, 1); } /* lift */
  63% { transform: translate3d(calc(0 * var(--u)), calc(-70 * var(--u)), calc(-60 * var(--u))) rotateY(0deg); animation-timing-function: ease-in-out; } /* across */
  67% { transform: translate3d(calc(52 * var(--u)), calc(-70 * var(--u)), calc(30 * var(--u))) rotateY(0deg); animation-timing-function: cubic-bezier(0.5, 0, 0.75, 0.4); } /* drop */
  70% { transform: translate3d(calc(52 * var(--u)), calc(-16 * var(--u)), calc(30 * var(--u))) rotateY(0deg); }
  80% { transform: translate3d(calc(52 * var(--u)), calc(-16 * var(--u)), calc(30 * var(--u))) rotateY(0deg); animation-timing-function: ease-in-out; }
  100% { transform: translate3d(calc(52 * var(--u)), calc(-16 * var(--u)), calc(30 * var(--u))) rotateY(120deg); }
}

@keyframes hanoi-disk3 {
  0% { transform: translate3d(calc(-52 * var(--u)), calc(0 * var(--u)), calc(30 * var(--u))) rotateY(0deg); }
  40% { transform: translate3d(calc(-52 * var(--u)), calc(0 * var(--u)), calc(30 * var(--u))) rotateY(0deg); animation-timing-function: cubic-bezier(0.3, 0, 0.3, 1); } /* lift */
  43% { transform: translate3d(calc(-52 * var(--u)), calc(-70 * var(--u)), calc(30 * var(--u))) rotateY(0deg); animation-timing-function: ease-in-out; } /* across */
  47% { transform: translate3d(calc(52 * var(--u)), calc(-70 * var(--u)), calc(30 * var(--u))) rotateY(0deg); animation-timing-function: cubic-bezier(0.5, 0, 0.75, 0.4); } /* drop */
  50% { transform: translate3d(calc(52 * var(--u)), calc(0 * var(--u)), calc(30 * var(--u))) rotateY(0deg); }
  80% { transform: translate3d(calc(52 * var(--u)), calc(0 * var(--u)), calc(30 * var(--u))) rotateY(0deg); animation-timing-function: ease-in-out; }
  100% { transform: translate3d(calc(52 * var(--u)), calc(0 * var(--u)), calc(30 * var(--u))) rotateY(120deg); }
}
Standalone snippet · plain CSS, no build step, no dependencies.