Pure CSS Shapes & solids #loop #shape

Octahedron in pure CSS

Eight triangles hinged on a square equator, leaning in by 35.26° so they meet at two apexes. One rule builds all of them: scaleY(-1) flips the bottom four.

  • 50 lines of CSS
  • 12 lines of HTML
  • No JavaScript
  • No dependencies
  • MIT licensed
GitHub

How it works

  1. An octahedron is two square pyramids glued at their base. Every face is the same equilateral triangle (clip-path), standing on one edge of the square "equator".
  2. Hinge each triangle on its bottom edge (transform-origin: 50% 100%) and lean it in by 90° − atan(√2) ≈ 35.26°: exactly enough for four tips to meet on the axis.
  3. The bottom pyramid reuses the same rule. --s: -1 flips the triangle to point down with scaleY(-1) and reverses the lean, so one line of CSS builds all eight faces.
  4. clip-path also clips borders away, so the slanted edges are painted: a to top left gradient's 50% line runs exactly along its box diagonal.

Key techniques

  • clip-path triangles
  • lean = 90° − atan(√2)
  • --s: 1 / −1 flips half the faces
  • edges drawn with "to corner" gradients

Copy-paste code

HTML 12 lines

<div class="scene">
  <div class="octa">
    <i style="--i:0; --s:1"></i>
    <i style="--i:1; --s:1"></i>
    <i style="--i:2; --s:1"></i>
    <i style="--i:3; --s:1"></i>
    <i style="--i:0; --s:-1"></i>
    <i style="--i:1; --s:-1"></i>
    <i style="--i:2; --s:-1"></i>
    <i style="--i:3; --s:-1"></i>
  </div>
</div>

CSS 50 lines

.scene {
  perspective: 800px;
}

.octa {
  --a: 110px; /* edge length */
  position: relative;
  width: var(--a);
  height: var(--a);
  transform-style: preserve-3d;
  animation: spin 12s linear infinite;
}

.octa i {
  --c: 139 108 255;
  --alpha: 0.22;
  --edge: rgb(var(--c) / 0.85);
  position: absolute;
  left: 0;
  bottom: 50%;             /* bottom edge on the equator, through the centre */
  width: var(--a);
  height: 95.26px;         /* triangle height = a × √3 / 2 */
  transform-origin: 50% 100%;
  transform:
    rotateY(calc(var(--i) * 90deg))
    translateZ(calc(var(--a) / 2))
    rotateX(calc(var(--s) * 35.26deg))  /* lean in: 90deg - atan(√2) */
    scaleY(var(--s));                   /* -1 flips the bottom four downward */
  clip-path: polygon(50% 0, 0 100%, 100% 100%);
  background:
    linear-gradient(to top left, transparent calc(50% - 1.5px), var(--edge) calc(50% - 1.5px) 50%, transparent 50%) left / 50% 100% no-repeat,
    linear-gradient(to top right, transparent calc(50% - 1.5px), var(--edge) calc(50% - 1.5px) 50%, transparent 50%) right / 50% 100% no-repeat,
    linear-gradient(var(--edge), var(--edge)) bottom / 100% 1.5px no-repeat,
    linear-gradient(to top, rgb(var(--c) / var(--alpha)), rgb(var(--c) / 0.08));
}

.octa i:nth-child(n + 5) {
  --c: 46 230 214;
}

/* checkerboard the brightness so neighbouring faces always differ */
.octa i:nth-child(-n + 4):nth-child(odd),
.octa i:nth-child(n + 5):nth-child(even) {
  --alpha: 0.42;
}

@keyframes spin {
  from { transform: rotateZ(14deg) rotateX(-16deg) rotateY(0deg); }
  to   { transform: rotateZ(14deg) rotateX(-16deg) rotateY(360deg); }
}

Sass source 60 lines

@use 'sass:math';

$a: 92px; // edge length
$t: $a * math.div(math.sqrt(3), 2); // height of one equilateral face
// A face meets the base plane at atan(sqrt 2) = 54.74deg. Faces start upright, so they lean the rest.
$lean: 90deg - math.atan(math.sqrt(2)); // 35.26deg
$edge: color-mix(in srgb, var(--c) 85%, transparent);

.d-octa {
  position: relative;
  width: $a;
  height: $a;
  transform-style: preserve-3d;
  animation: d-octa-spin 12s linear infinite;

  // Every face is the same upward triangle standing on the square "equator", hinged on its
  // bottom edge. --s is 1 for the top pyramid and -1 for the bottom one: it flips the triangle
  // downwards (scaleY) and reverses the lean, so one rule builds all eight faces.
  i {
    --c: var(--accent);
    --a: 22%;
    position: absolute;
    left: 0;
    bottom: 50%;
    width: $a;
    height: $t;
    transform-origin: 50% 100%;
    transform: rotateY(calc(var(--i) * 90deg)) translateZ($a * 0.5) rotateX(calc(var(--s) * #{$lean})) scaleY(var(--s));
    clip-path: polygon(50% 0, 0 100%, 100% 100%);
    // Borders are clipped away with the corners, so the edges are drawn: a "to corner" gradient's
    // 50% line runs exactly along the diagonal of its box, and each half of the face gets one.
    background:
      linear-gradient(to top left, transparent calc(50% - 1.5px), $edge calc(50% - 1.5px), $edge 50%, transparent 50%) left /
        50% 100% no-repeat,
      linear-gradient(to top right, transparent calc(50% - 1.5px), $edge calc(50% - 1.5px), $edge 50%, transparent 50%)
        right / 50% 100% no-repeat,
      linear-gradient($edge, $edge) bottom / 100% 1.5px no-repeat,
      linear-gradient(to top, color-mix(in srgb, var(--c) var(--a), transparent), color-mix(in srgb, var(--c) 8%, transparent));

    // checkerboard the brightness so neighbouring faces (also across the equator) differ
    &:nth-child(-n + 4):nth-child(odd),
    &:nth-child(n + 5):nth-child(even) {
      --a: 42%;
    }

    &:nth-child(n + 5) {
      --c: var(--accent-2);
    }
  }
}

@keyframes d-octa-spin {
  from {
    transform: rotateZ(14deg) rotateX(-16deg) rotateY(0deg);
  }

  to {
    transform: rotateZ(14deg) rotateX(-16deg) rotateY(360deg);
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.