CSS + JS Shapes & solids #controls #shape

Dice roll in CSS + JavaScript

JS picks a random face and adds full turns; a CSS transition does the tumbling.

  • 120 lines of CSS
  • 16 lines of HTML
  • 19 lines of JS
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. The die is the plain CSS cube. Each value has a known rotation that brings its face to the front.
  2. JS picks a random value, looks up that rotation and adds two more full turns (+720°) on every roll, so it always tumbles instead of taking a shortcut.
  3. It writes two custom properties. The 1.1s transition with an overshoot easing is the roll animation.
  4. A fixed camera tilt comes first in the transform list, so you always see three faces.
  5. Rounded faces leave see-through holes at the cube’s corners. A square plate 8 units behind every face builds a sharp inner cube that fills them.
  6. Every length in the die is a multiple of one base unit, --u, tied to the canvas, so it is the same share of a gallery card, the editor and a recording canvas. The Roll button and its caption are in plain vmin instead: they are the same size in every model.

Key techniques

  • Math.random() → target rotation
  • accumulating 360° turns
  • transition with overshoot easing

Copy-paste code

HTML 16 lines

<div class="table">
  <div class="view">
    <div class="scene">
      <div class="cube">
        <div>1</div><div>2</div><div>6</div>
        <div>5</div><div>3</div><div>4</div>
      </div>
    </div>
  </div>
  <div class="controls">
    <output class="caption">Click roll</output>
    <div class="row">
      <button type="button">Roll</button>
    </div>
  </div>
</div>

CSS 120 lines

.table {
  /* one base unit: every length in the die 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 under it is in
     plain vmin, because it is the same object in every model. */
  --u: 0.21vmin;
  display: grid;
  justify-items: center;
  gap: 4vmin; /* the band's gap between the model and the control zone */
  font-family: Inter, system-ui;
}

/* the model box: the same height in every model that has controls, so the zone below it lands
   in the same place whatever the model is */
.view {
  display: grid;
  place-items: center;
  height: 44vmin;
}

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

.cube {
  --s: calc(120 * var(--u));
  position: relative;
  width: var(--s);
  height: var(--s);
  transform-style: preserve-3d;
  transform:
    rotateX(-22deg) rotateY(-28deg)                       /* camera */
    rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));    /* the roll, from JS */
  transition: transform 1.1s cubic-bezier(0.2, 0.9, 0.3, 1.15);
}

.cube > * {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  /* an inner line plus a soft shade instead of a hard border: squeezed side-on, a calc(2 * var(--u)) line
     breaks up, the shade survives */
  box-shadow: inset 0 0 0 calc(1.5 * var(--u)) #c9cbe0, inset 0 0 calc(8 * var(--u)) rgb(150 154 190 / 0.45);
  border-radius: calc(16 * var(--u));
  background: radial-gradient(circle at 30% 30%, #fff, #dfe1f0);
  color: #1a1d33;
  font: 900 calc(54 * var(--u)) Inter, system-ui;
  transform-style: preserve-3d;
}

/* Rounded faces leave a hole where three corners meet. A square plate just behind each face
   forms a slightly smaller inner cube that plugs the gaps. */
.cube > *::before {
  content: '';
  position: absolute;
  inset: calc(8 * var(--u));
  background: #d3d6ea;
  transform: translateZ(calc(-8 * var(--u)));
}

/* turn each face outward, then push it half a side from the centre */
.cube > :nth-child(1) { transform: rotateY(0deg)   translateZ(calc(var(--s) / 2)); }
.cube > :nth-child(2) { transform: rotateY(90deg)  translateZ(calc(var(--s) / 2)); }
.cube > :nth-child(3) { transform: rotateY(180deg) translateZ(calc(var(--s) / 2)); }
.cube > :nth-child(4) { transform: rotateY(-90deg) translateZ(calc(var(--s) / 2)); }
.cube > :nth-child(5) { transform: rotateX(90deg)  translateZ(calc(var(--s) / 2)); }
.cube > :nth-child(6) { transform: rotateX(-90deg) translateZ(calc(var(--s) / 2)); }

/* the control zone: the same object, at the same size, in every model that has one — so it is
   written in plain vmin and not in the die's own unit. The caption is on its own line above the
   row, and its line box never changes height, so a new result cannot move the die. */
.controls {
  display: grid;
  justify-items: center;
  gap: 2vmin;
  text-align: center;
}

.controls .caption {
  font: 500 4.5vmin/1.2 Inter, system-ui, sans-serif;
  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;
}

JS 19 lines

const cube = document.querySelector('.cube');
const out = document.querySelector('output');

// rotation [x, y] that brings each value to the front
// (faces in the markup: front 1, right 2, back 6, left 5, top 3, bottom 4)
const views = { 1: [0, 0], 2: [0, -90], 6: [0, -180], 5: [0, 90], 3: [-90, 0], 4: [90, 0] };
let turns = 0;

document.querySelector('button').addEventListener('click', () => {
  const value = 1 + Math.floor(Math.random() * 6);
  const [x, y] = views[value];
  turns += 2;   // two extra full turns per roll

  cube.style.setProperty('--rx', x + 360 * turns + 'deg');
  cube.style.setProperty('--ry', y + 360 * turns + 'deg');

  out.textContent = 'Rolling…';
  setTimeout(() => (out.textContent = 'You rolled ' + value), 1100);
});
Standalone snippet · plain CSS, no build step, no dependencies.