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.

  • 62 lines of CSS
  • 10 lines of HTML
  • 19 lines of JS
  • No dependencies
  • MIT licensed
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 8px behind every face builds a sharp inner cube that fills them.

Key techniques

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

Copy-paste code

HTML 10 lines

<div class="table">
  <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>
  <button type="button">Roll</button>
  <output>Click roll</output>
</div>

CSS 62 lines

.table {
  display: grid;
  justify-items: center;
  gap: 50px;
  font-family: system-ui;
}

.scene {
  perspective: 700px;
}

.cube {
  --s: 120px;
  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;
  border: 2px solid #c9cbe0;
  border-radius: 16px;
  background: radial-gradient(circle at 30% 30%, #fff, #dfe1f0);
  color: #1a1d33;
  font: 900 3.4rem 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: 8px;
  background: #d3d6ea;
  transform: translateZ(-8px);
}

/* 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)); }

button {
  padding: 8px 26px;
  border: 0;
  border-radius: 10px;
  background: #ffb547;
  font: 800 1rem system-ui;
  cursor: pointer;
}

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);
});

Sass source 64 lines

@use '../mixins' as *;

.d-dice {
  display: grid;
  grid-template-rows: 1fr auto;
  width: 100%;
  height: 100%;
  padding: 10px 14px;

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

  &__cube {
    --s: 92px;
    position: relative;
    width: var(--s);
    height: var(--s);
    transform-style: preserve-3d;
    // A fixed camera tilt first, then the roll. JS only ever changes --rx / --ry;
    // the long transition is the "tumble".
    transform: rotateX(-22deg) rotateY(-28deg) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
    transition: transform 1.1s cubic-bezier(0.2, 0.9, 0.3, 1.15);
    @include cube-faces(var(--s));

    i {
      position: absolute;
      inset: 0;
      display: grid;
      place-items: center;
      border: 2px solid #c9cbe0;
      border-radius: 14px;
      background: radial-gradient(circle at 30% 30%, #fff, #dfe1f0);
      color: #1a1d33;
      font-size: 44px;
      font-style: normal;
      font-weight: 900;
      @include solid-core(6px, #d3d6ea);
    }
  }

  &__bar {
    display: flex;
    align-items: center;
    gap: 10px;
    font-size: 13px;

    button {
      padding: 5px 16px;
      border: 0;
      border-radius: 8px;
      background: var(--warm);
      color: #1b1408;
      font-weight: 800;
      cursor: pointer;
    }

    output {
      color: var(--muted);
    }
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.