CSS + JS Shapes & solids #controls #generated #shape #loop

Shapeshifting prism in CSS + JavaScript

Pick 3 to 12 sides. JS rebuilds the panels and computes the apothem and side length with cos() and sin(); CSS places every panel from those numbers.

  • 82 lines of CSS
  • 6 lines of HTML
  • 31 lines of JS
  • No dependencies
  • MIT licensed

How it works

  1. Keep the corner radius R fixed and derive the rest: side = 2R × sin(180° / n), apothem = R × cos(180° / n). JS computes both and hands them to CSS as --w and --r.
  2. CSS places every panel from those numbers: rotateY(calc(var(--i) * 1turn / var(--n))) translateZ(var(--r)). JS never touches a transform.
  3. The caps are a 2R square cut to the polygon. JS writes the clip-path: one corner every 360° / n, starting half a side from the centre of panel 0.
  4. Changing n rebuilds the panels. @starting-style gives brand-new elements a first frame to transition from, so they fly in without any animation JS.

Key techniques

  • JS: r = R·cos(180°/n), side = 2R·sin(180°/n)
  • rotateY(calc(i × 1turn / n))
  • generated clip-path polygon caps
  • @starting-style fly-in

Copy-paste code

HTML 6 lines

<div class="app">
  <div class="scene">
    <div class="prism"></div>
  </div>
  <label>Sides <input type="range" min="3" max="12" value="6" /> <output>6</output></label>
</div>

CSS 82 lines

.app {
  display: grid;
  justify-items: center;
  gap: 48px;
}

.scene {
  perspective: 800px;
}

.prism {
  --h: 120px;
  position: relative;
  width: calc(var(--R) * 2);
  height: var(--h);
  transform-style: preserve-3d;
  animation: spin 14s linear infinite;
}

/* JS sets --n, --R, --r (apothem), --w (side) and --cap; CSS only places things */
.prism i {
  --hue: calc(214 - 39 * cos(var(--i) * 1turn / var(--n))); /* teal ... violet ... teal */
  position: absolute;
  top: 0;
  left: calc(50% - var(--w) / 2);
  width: var(--w);
  height: 100%;
  background: hsl(var(--hue) 85% 64% / 0.26);
  border: 1px solid hsl(var(--hue) 85% 64% / 0.75);
  box-shadow: inset 0 0 24px hsl(var(--hue) 85% 64% / 0.3);
  transform: rotateY(calc(var(--i) * 1turn / var(--n))) translateZ(var(--r));
  transition: transform 0.55s cubic-bezier(0.2, 0.9, 0.3, 1.1), opacity 0.35s;
  transition-delay: calc(var(--i) * 30ms);
}

/* the first frame of a newly inserted panel: further out and invisible */
@starting-style {
  .prism i {
    opacity: 0;
    transform: rotateY(calc(var(--i) * 1turn / var(--n))) translateZ(calc(var(--r) + 70px));
  }
}

/* caps: both rotateX(90deg), pushed up or down, so an odd polygon lines up on both ends */
.prism b {
  position: absolute;
  left: calc(50% - var(--R));
  top: calc(50% - var(--R));
  width: calc(var(--R) * 2);
  height: calc(var(--R) * 2);
  clip-path: var(--cap);
  background: radial-gradient(circle, rgb(255 77 157 / 0.1) 15%, rgb(255 77 157 / 0.5));
  transform: rotateX(90deg) translateZ(calc(var(--h) / 2));
  transition: opacity 0.4s 0.25s;
}

.prism b + b {
  transform: rotateX(90deg) translateZ(calc(var(--h) / -2));
}

@starting-style {
  .prism b { opacity: 0; }
}

label {
  display: flex;
  align-items: center;
  gap: 10px;
  color: #949bc0;
  font: 14px system-ui;
}

output {
  min-width: 2ch;
  color: #eceefb;
  font-family: ui-monospace, monospace;
}

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

JS 31 lines

const prism = document.querySelector('.prism');
const input = document.querySelector('input');
const output = document.querySelector('output');
const R = 80; // corner radius: the prism keeps this footprint for every n

function build() {
  const n = Number(input.value);
  const half = Math.PI / n; // half the angle one side spans

  prism.style.setProperty('--n', n);
  prism.style.setProperty('--R', R + 'px');
  prism.style.setProperty('--r', R * Math.cos(half) + 'px');     // apothem
  prism.style.setProperty('--w', 2 * R * Math.sin(half) + 'px'); // side length

  // cap outline: a corner half a side either side of every panel's centre
  const corners = [];
  for (let k = 0; k < n; k++) {
    const a = (2 * k + 1) * half;
    corners.push((50 + 50 * Math.sin(a)) + '% ' + (50 + 50 * Math.cos(a)) + '%');
  }
  prism.style.setProperty('--cap', 'polygon(' + corners.join(', ') + ')');

  // brand-new elements, so @starting-style flies them in
  let html = '';
  for (let i = 0; i < n; i++) html += '<i style="--i:' + i + '"></i>';
  prism.innerHTML = html + '<b></b><b></b>';
  output.textContent = n;
}

input.addEventListener('input', build);
build();

Sass source 116 lines

@use '../mixins' as *;

.d-shapeshift {
  display: grid;
  grid-template-rows: 1fr auto;
  width: 100%;
  height: 100%;
  padding: 10px 14px 14px; // the control dock: the same place in every demo with controls

  &__view {
    display: grid;
    place-items: center;
    perspective: 700px;
  }

  // JS sets --n (sides), --R (corner radius), --r (apothem = R·cos(180°/n)),
  // --w (side = 2R·sin(180°/n)) and --cap (the n-gon as a clip-path polygon).
  // CSS only places things from those numbers.
  &__prism {
    --h: 92px;
    position: relative;
    width: calc(var(--R) * 2);
    height: var(--h);
    transform-style: preserve-3d;
    animation: d-shapeshift-spin 14s linear infinite;

    // side panel i: turn to its own angle, then walk out by the apothem
    i {
      --c: color-mix(in srgb, var(--accent-2) var(--mix, 50%), var(--accent));
      position: absolute;
      top: 0;
      left: calc(50% - var(--w) / 2);
      width: var(--w);
      height: 100%;
      transform: rotateY(calc(var(--i) * 1turn / var(--n))) translateZ(var(--r));
      // panels are rebuilt on every change; @starting-style (below) makes new ones fly in
      transition:
        transform 0.55s cubic-bezier(0.2, 0.9, 0.3, 1.1),
        opacity 0.35s;
      transition-delay: calc(var(--i) * 30ms);
      @include face(var(--c), 26%);

      @starting-style {
        opacity: 0;
        transform: rotateY(calc(var(--i) * 1turn / var(--n))) translateZ(calc(var(--r) + 70px));
      }
    }

    // caps: a 2R square cut to the n-gon, laid flat at the top and bottom. Both use rotateX(90deg)
    // and only differ in the push, so the (not always symmetric) polygon lines up on both ends.
    b {
      position: absolute;
      left: calc(50% - var(--R));
      top: calc(50% - var(--R));
      width: calc(var(--R) * 2);
      height: calc(var(--R) * 2);
      clip-path: var(--cap);
      background: radial-gradient(
        circle,
        color-mix(in srgb, var(--hot) 10%, transparent) 15%,
        color-mix(in srgb, var(--hot) 50%, transparent) 100%
      );
      transform: rotateX(90deg) translateZ(calc(var(--h) / 2));
      transition: opacity 0.4s 0.25s;

      & + b {
        transform: rotateX(90deg) translateZ(calc(var(--h) / -2));
      }

      @starting-style {
        opacity: 0;
      }
    }
  }

  // the site's control pattern: the status centred on top, the control centred under it
  &__bar {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: center;
    gap: 4px 10px;
    width: min(100%, 300px);
    margin-inline: auto;
    color: var(--muted);
    font-size: 13px;

    input {
      flex: 1 1 100%;
      min-width: 0;
      accent-color: var(--accent);
    }

    label {
      order: -2;
    }

    output {
      order: -1;
      color: var(--text);
      font-family: var(--mono);
      font-size: 12px;
      text-align: center;
    }
  }
}

@keyframes d-shapeshift-spin {
  from {
    transform: rotateX(-22deg) rotateY(0deg);
  }

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