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.

  • 213 lines of CSS
  • 13 lines of HTML
  • 38 lines of JS
  • No dependencies
  • Snippet under MIT
GitHub

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 — plain numbers, never lengths: CSS multiplies them by --u, the one base unit every length here is a multiple of, so the prism is the same share of a gallery card, the editor and a recording canvas.
  2. CSS places every panel from those numbers: rotateY(calc(var(--i) * 1turn / var(--n))) translateZ(var(--apothem)), where --apothem is calc(var(--r) * var(--u)). 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 grow out from the axis without any animation JS. It is scoped to a .grow class that JS adds only once the slider moves, so the prism the page opens on is whole from its very first frame, and a paused card shows it full-sized.
  5. The slider is the control block’s own: track and thumb in vmin, so it is the same share of a card and a full screen. build() also writes --pos, how far along the slider is from 0 to 1, which is where its filled track ends.

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 grow-in from the axis

Copy-paste code

HTML 13 lines

<div class="band">
  <div class="view">
    <div class="scene">
      <div class="prism"></div>
    </div>
  </div>
  <div class="controls">
    <output class="caption">6 sides</output>
    <div class="row">
      <label>Sides <input type="range" min="3" max="12" value="6" aria-label="Number of sides" /></label>
    </div>
  </div>
</div>

CSS 213 lines

/* the prism and the control zone stand in one stack, so the zone is the same distance below
   the model in every model */
.band {
  /* one base unit: every length in the prism is a multiple of it, so it is the same share of a
     card, the editor, a full screen and a recording canvas. The control zone is in plain vmin,
     because it is the same object in every model. */
  --u: 0.26vmin;
  display: grid;
  justify-items: center;
  gap: 4vmin;
}

/* the model box */
.view {
  display: grid;
  place-items: center;
  height: 50vmin;
}

.scene {
  perspective: calc(800 * var(--u));
}

.prism {
  --h: calc(120 * var(--u));
  position: relative;
  /* JS writes --R, --r and --w as plain numbers; they become lengths here, in the model's unit */
  --radius: calc(var(--R) * var(--u));
  --apothem: calc(var(--r) * var(--u));
  --side: calc(var(--w) * var(--u));
  width: calc(var(--radius) * 2);
  height: var(--h);
  transform-style: preserve-3d;
  animation: spin 14s linear infinite;
}

/* JS sets --n, --R (radius), --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(--side) / 2);
  /* one side wide, border included, so neighbouring faces meet edge to edge */
  box-sizing: border-box;
  width: var(--side);
  height: 100%;
  background: hsl(var(--hue) 85% 64% / 0.26);
  border: calc(1 * var(--u)) solid hsl(var(--hue) 85% 64% / 0.75);
  box-shadow: inset 0 0 calc(24 * var(--u)) hsl(var(--hue) 85% 64% / 0.3);
  transform: rotateY(calc(var(--i) * 1turn / var(--n))) translateZ(var(--apothem));
  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: on the axis and invisible, so it grows out into place
   and never reaches past the prism's own footprint on the way. Only once the slider has moved
   (.grow): the prism the page opens on starts whole, so its first frame, the one a paused card
   holds, is the full-sized shape */
@starting-style {
  .prism.grow i {
    opacity: 0;
    transform: rotateY(calc(var(--i) * 1turn / var(--n))) translateZ(0);
  }
}

/* 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(--radius));
  top: calc(50% - var(--radius));
  width: calc(var(--radius) * 2);
  height: calc(var(--radius) * 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.grow b { opacity: 0; }
}

/* the control zone: the same object, at the same size, in every model that has one */
.controls {
  display: grid;
  justify-items: center;
  gap: 2vmin;
  text-align: center;
}

.controls .caption {
  font: 500 4.5vmin/1.2 Inter, system-ui, sans-serif;
  font-variant-numeric: tabular-nums;
  opacity: 0.7;
}

.controls .row {
  display: flex;
  gap: 2vmin;
}

.controls button,
.controls label {
  height: 8vmin;
  min-width: 8vmin;
  padding: 0 3vmin;
  box-sizing: border-box; /* a label is content-box, so min-width would add to its padding */
  border: 0;
  border-radius: 999px;
  /* see-through, so the pill sits on the stage, and its word is the stage's own ink at full
     strength: no colour of the model's, which would vanish on one of the two stages */
  background: rgb(140 150 220 / 0.2);
  color: inherit;
  font: 600 4vmin Inter, system-ui, sans-serif;
  cursor: pointer;
  transition: background-color 0.35s;
}

.controls button:hover,
.controls label:hover {
  background-color: rgb(140 150 220 / 0.34);
}

.controls :focus-visible {
  outline: 0.6vmin solid #6a45f5;
  outline-offset: 0.6vmin;
}

/* the selected pill: the brand gradient, deep enough that white text on it passes */
.controls [aria-pressed='true'] {
  background: linear-gradient(135deg, #6a45f5, #d1206f);
  color: #fff;
}

/* a slider, inside its pill: track and thumb in vmin, never the browser's own px, so it is the
   same share of every canvas. The empty track is the unselected tint and the filled part the
   selected gradient; the thumb is the stage's ink in a gradient ring, because the gradient alone
   is under 3:1 on the pill's tint on the dark stage. Chrome has no filled part of its own: the
   model's JS writes --pos (0 to 1) on the input as it moves, and the fill ends under the thumb */
.controls input[type='range'] {
  --thumb: 4.5vmin;
  appearance: none;
  width: 28vmin;
  height: var(--thumb);
  margin: 0; /* the browser's is 2px */
  background: none;
  color: inherit; /* the thumb is the stage's ink: a form control does not inherit it by itself */
  cursor: pointer;
}
.controls input[type='range']::-webkit-slider-runnable-track {
  height: 1.2vmin;
  border-radius: 999px;
  background:
    linear-gradient(90deg, #6a45f5, #d1206f) 0 0 / calc(var(--thumb) / 2 + var(--pos, 0) * (100% - var(--thumb))) 100% no-repeat,
    rgb(140 150 220 / 0.2);
}
.controls input[type='range']::-webkit-slider-thumb {
  appearance: none;
  box-sizing: border-box;
  width: var(--thumb);
  height: var(--thumb);
  margin-top: calc((1.2vmin - var(--thumb)) / 2);
  border: 0.8vmin solid transparent;
  border-radius: 50%;
  background:
    linear-gradient(currentColor, currentColor) padding-box,
    linear-gradient(135deg, #6a45f5, #d1206f) border-box;
}
.controls input[type='range']::-moz-range-track {
  height: 1.2vmin;
  border-radius: 999px;
  background: rgb(140 150 220 / 0.2);
}
.controls input[type='range']::-moz-range-progress {
  height: 1.2vmin;
  border-radius: 999px;
  background: linear-gradient(90deg, #6a45f5, #d1206f);
}
.controls input[type='range']::-moz-range-thumb {
  box-sizing: border-box;
  width: var(--thumb);
  height: var(--thumb);
  border: 0.8vmin solid transparent;
  border-radius: 50%;
  background:
    linear-gradient(currentColor, currentColor) padding-box,
    linear-gradient(135deg, #6a45f5, #d1206f) border-box;
}
/* focused from the keyboard, the ring goes round the whole pill, out on the stage like every
   other control's: round the thumb it would sit on the tint, where #6a45f5 is under 3:1 */
.controls input[type='range']:focus-visible {
  outline: 0;
}
.controls label:has(input[type='range']:focus-visible) {
  outline: 0.6vmin solid #6a45f5;
  outline-offset: 0.6vmin;
}

.controls label {
  display: flex;
  align-items: center;
  gap: 2vmin;
}


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

JS 38 lines

const prism = document.querySelector('.prism');
const input = document.querySelector('input');
const output = document.querySelector('output');
const R = 80; // corner radius, in the model's units: 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);
  // plain numbers: CSS multiplies them by --u, so the prism scales with the canvas
  prism.style.setProperty('--R', R);
  prism.style.setProperty('--r', R * Math.cos(half));     // apothem
  prism.style.setProperty('--w', 2 * R * Math.sin(half)); // 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 + ' sides';
  // how far along the slider is, 0 to 1: the filled part of its track
  input.style.setProperty('--pos', (n - input.min) / (input.max - input.min));
}

// the first build stands whole; every rebuild after it flies its new panels in
input.addEventListener('input', () => {
  prism.classList.add('grow');
  build();
});
build();
Standalone snippet · plain CSS, no build step, no dependencies.