← Numbers

Dice Roll

A 3D die tumbles in from the side, bouncing lower each time and spinning end over end, then settles on a number. Loop it and it rolls away and comes back with a new number; click or tap to roll again. Roll two dice and show the total.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-dice-roll to an empty element for a random roll, or data-dice-roll="6" to land on 6. Size it with font-size; each die is 2.6em.

Options

Add any of these attributes to the same element.

data-diceHow many dice. Default 1.
data-dice-roll="3,4|6,6"Fixed results, taken in turn: dice separated by commas, rolls by |.
data-loopRoll again every few seconds.
data-holdHow long each result stays. Default 2200 ms.
data-durationHow long a roll takes. Default 1500 ms.
data-totalShow the result (or the sum of the dice) underneath.
data-color / data-pipDie and pip colours. Default white and #1c1c24.

The die takes the full width of its container so it has room to roll in. Click or tap it to roll.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Dice Roll: a 3D die tumbles in from the side, bouncing and spinning, and
    // lands showing a number. Loop it and it rolls away and comes back with a
    // new number; click or tap to roll again.
    // Usage: <div data-dice-roll></div>        (a random number each roll)
    //        <div data-dice-roll="6"></div>     (always lands on 6)
    // Optional: data-dice (how many dice, default 1), data-dice-roll="3,4|6,6"
    // (fixed results: dice separated by commas, rolls by |, taken in turn),
    // data-loop (roll again every few seconds), data-hold (ms each result
    // stays, default 2200), data-duration (ms a roll takes, default 1500),
    // data-total (show the total under the dice), data-color (die colour,
    // default white), data-pip (pip colour, default #1c1c24).
    // Size it with font-size: each die is 2.6em, the whole thing 100% wide.
    var DEFAULTS = { dice: 1, hold: 2200, duration: 1500, color: '#ffffff', pip: '#1c1c24' };
    var PIPS = { 1: [4], 2: [2, 6], 3: [2, 4, 6], 4: [0, 2, 6, 8], 5: [0, 2, 4, 6, 8], 6: [0, 2, 3, 5, 6, 8] };
    // Where each value sits on the cube (rotation of its face) and the cube
    // rotation that turns that face to the front.
    var FACE = { 1: 'rotateY(0deg)', 6: 'rotateY(180deg)', 3: 'rotateY(90deg)', 4: 'rotateY(-90deg)', 2: 'rotateX(90deg)', 5: 'rotateX(-90deg)' };
    var SHOW = { 1: [0, 0], 6: [0, 180], 3: [0, -90], 4: [0, 90], 2: [-90, 0], 5: [90, 0] };

    var css =
      '.dcr{--dcr-s:2.6em;position:relative;width:100%;overflow:hidden;cursor:pointer;-webkit-tap-highlight-color:transparent;user-select:none}' +
      '.dcr-floor{position:relative;height:calc(var(--dcr-s) * 2.5)}' +
      '.dcr-die{position:absolute;left:50%;bottom:calc(var(--dcr-s) * .35);width:var(--dcr-s);height:var(--dcr-s);margin-left:calc(var(--dcr-s) * -.5);perspective:calc(var(--dcr-s) * 8)}' +
      '.dcr-shadow{position:absolute;left:-10%;right:-10%;bottom:calc(var(--dcr-s) * -.22);height:calc(var(--dcr-s) * .3);border-radius:50%;background:radial-gradient(closest-side,rgba(20,10,40,.32),rgba(20,10,40,0))}' +
      '.dcr-view{position:absolute;inset:0;transform-style:preserve-3d;transform:rotateX(-20deg) rotateY(24deg)}' +
      '.dcr-cube{position:absolute;inset:0;transform-style:preserve-3d}' +
      '.dcr-face,.dcr-core{position:absolute;inset:0;border-radius:18%;backface-visibility:hidden}' +
      '.dcr-face{display:grid;grid-template:repeat(3,1fr)/repeat(3,1fr);padding:14%;box-sizing:border-box;background:var(--dcr-c);' +
      'box-shadow:inset 0 0 0 calc(var(--dcr-s) * .02) rgba(0,0,0,.08),inset 0 calc(var(--dcr-s) * -.08) calc(var(--dcr-s) * .16) rgba(0,0,0,.12)}' +
      '.dcr-core{backface-visibility:visible;border-radius:4%;background:var(--dcr-c);filter:brightness(.82)}' +
      '.dcr-face i{display:block;width:72%;height:72%;margin:auto;border-radius:50%;background:var(--dcr-p);box-shadow:inset 0 .06em .08em rgba(0,0,0,.45)}' +
      '.dcr-face[data-v="1"] i{width:100%;height:100%}' +
      '.dcr-total{min-height:1.5em;padding-bottom:.15em;line-height:1.3;text-align:center;font-weight:700;font-size:.62em;letter-spacing:-.01em;font-variant-numeric:tabular-nums;color:inherit}' +
      '.dcr-total b{display:inline-block;font-size:1.6em;font-weight:900;line-height:1;vertical-align:-.12em}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function rand(a, b) { return a + Math.random() * (b - a); }

    function faceHtml(v, tf) {
      var cells = '';
      for (var i = 0; i < 9; i++) cells += PIPS[v].indexOf(i) >= 0 ? '<i></i>' : '<span></span>';
      return '<div class="dcr-face" data-v="' + v + '" style="transform:' + tf + ' translateZ(calc(var(--dcr-s) * .5))">' + cells + '</div>';
    }

    function setup(el) {
      var d = el.dataset, attr = el.getAttribute('data-dice-roll') || '';
      var rounds = attr.split('|').map(function (r) { return r.split(/[,\s]+/).filter(Boolean).map(Number); }).filter(function (r) { return r.length; });
      var D = {
        el: el, rounds: rounds, round: 0, count: +d.dice || (rounds[0] ? rounds[0].length : DEFAULTS.dice),
        hold: +d.hold || DEFAULTS.hold, duration: +d.duration || DEFAULTS.duration, total: d.total !== undefined,
        loop: d.loop !== undefined && d.loop !== 'false', still: reduced(), visible: false, rolling: false, timer: 0, dice: []
      };
      el.classList.add('dcr');
      el.style.setProperty('--dcr-c', d.color || DEFAULTS.color);
      el.style.setProperty('--dcr-p', d.pip || DEFAULTS.pip);
      var faces = '', v;
      for (v = 1; v <= 6; v++) faces += faceHtml(v, FACE[v]);
      var core = '<div class="dcr-core" style="transform:scale(.97)"></div><div class="dcr-core" style="transform:rotateX(90deg) scale(.97)"></div><div class="dcr-core" style="transform:rotateY(90deg) scale(.97)"></div>';
      var html = '<div class="dcr-floor" aria-hidden="true">';
      for (var k = 0; k < D.count; k++) html += '<div class="dcr-die"><div class="dcr-shadow"></div><div class="dcr-view"><div class="dcr-cube">' + core + faces + '</div></div></div>';
      el.innerHTML = html + '</div>' + (D.total ? '<div class="dcr-total" aria-hidden="true"></div>' : '');
      D.totalEl = el.querySelector('.dcr-total');
      el.setAttribute('role', 'img');
      [].forEach.call(el.querySelectorAll('.dcr-die'), function (node, i) {
        D.dice.push({ node: node, cube: node.querySelector('.dcr-cube'), shadow: node.querySelector('.dcr-shadow'), slot: (i - (D.count - 1) / 2) * 1.7, value: 1 });
      });
      el.addEventListener('click', function () { if (!D.rolling) { clearTimeout(D.timer); exit(D); } });
      return D;
    }

    function nextValues(D) {
      var r = D.rounds.length ? D.rounds[D.round++ % D.rounds.length] : [];
      return D.dice.map(function (die, i) { return Math.max(1, Math.min(6, r[i] || Math.floor(rand(1, 7)))); });
    }

    function label(D, vals) {
      var sum = vals.reduce(function (a, b) { return a + b; }, 0);
      D.el.setAttribute('aria-label', (vals.length > 1 ? 'Dice rolled ' + vals.join(' and ') + ', total ' : 'Die rolled ') + sum);
      if (D.totalEl) D.totalEl.innerHTML = vals.length > 1 ? vals.join(' + ') + ' = <b>' + sum + '</b>' : 'You rolled <b>' + sum + '</b>';
    }

    function pose(die, x, lift, rx, ry, rz) {
      die.node.style.transform = 'translate(' + x.toFixed(1) + 'px,' + (-lift).toFixed(1) + 'px)';
      die.cube.style.transform = 'rotateZ(' + rz.toFixed(1) + 'deg) rotateX(' + rx.toFixed(1) + 'deg) rotateY(' + ry.toFixed(1) + 'deg)';
      var h = Math.min(1, lift / (die.node.offsetHeight * 1.2 || 1));
      die.shadow.style.transform = 'translateY(' + lift.toFixed(1) + 'px) scale(' + (1 - h * 0.45).toFixed(3) + ')';
      die.shadow.style.opacity = (1 - h * 0.6).toFixed(3);
    }

    function roll(D) {
      if (!D.visible) return;
      D.rolling = true;
      var vals = nextValues(D), size = D.dice[0].node.offsetWidth, half = D.el.clientWidth / 2, done = 0;
      if (D.totalEl) anime.animate(D.totalEl, { opacity: 0, duration: 150 });
      D.dice.forEach(function (die, i) {
        var v = vals[i], end = SHOW[v], fx = die.slot * size;
        var sx = -half - size * 1.5 - fx, rx0 = end[0] - 720 - Math.round(rand(0, 1)) * 90, ry0 = end[1] - 360 - Math.round(rand(0, 2)) * 90;
        var rz0 = -540 + rand(-60, 60), rzT = rand(-10, 10), bounces = rand(2.6, 3.4), H = size * rand(0.8, 1.05);
        var st = { t: 0 };
        pose(die, sx, 0, rx0, ry0, rz0);
        die.anim = anime.animate(st, {
          t: 1, duration: D.duration + i * 160, delay: i * 120, ease: 'linear',
          onUpdate: function () {
            var t = st.t, e = 1 - Math.pow(1 - t, 3);
            var lift = Math.abs(Math.sin(t * Math.PI * bounces)) * Math.pow(1 - t, 1.6) * H;
            pose(die, sx + (fx - sx) * e, lift, rx0 + (end[0] - rx0) * e, ry0 + (end[1] - ry0) * e, rz0 + (rzT - rz0) * e);
          },
          onComplete: function () {
            die.rest = { x: fx, rx: end[0], ry: end[1], rz: rzT };
            pose(die, fx, 0, end[0], end[1], rzT);
            die.value = v;
            if (++done === D.dice.length) landed(D, vals);
          }
        });
      });
    }

    function landed(D, vals) {
      D.rolling = false;
      label(D, vals);
      if (D.totalEl) {
        anime.animate(D.totalEl, { opacity: [0, 1], duration: 250 });
        var b = D.totalEl.querySelector('b');
        if (b) anime.animate(b, { scale: [0.4, 1], duration: 500, ease: 'outBack(3)' });
      }
      if (D.loop) D.timer = setTimeout(function () { exit(D); }, D.hold);
    }

    // Rolls off to the right before the next roll comes in from the left.
    function exit(D) {
      if (!D.visible) return;
      if (!D.dice[0].rest) { roll(D); return; }
      D.rolling = true;
      var size = D.dice[0].node.offsetWidth, far = D.el.clientWidth / 2 + size * 2, n = 0;
      D.dice.forEach(function (die) {
        var r = die.rest, st = { t: 0 };
        anime.animate(st, {
          t: 1, duration: 520, ease: 'in(2)',
          onUpdate: function () { pose(die, r.x + (far - r.x + size) * st.t, Math.sin(st.t * Math.PI) * size * 0.4, r.rx, r.ry + 200 * st.t, r.rz + 320 * st.t); },
          onComplete: function () { if (++n === D.dice.length) roll(D); }
        });
      });
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var D = e.target._dcr;
          D.visible = e.isIntersecting;
          if (D.visible && !D.rolling) { clearTimeout(D.timer); D.timer = setTimeout(function () { exit(D); }, D.dice[0].rest ? 600 : 200); }
          if (!D.visible) clearTimeout(D.timer);
        });
      }, { threshold: 0.25 });
      document.querySelectorAll('[data-dice-roll]').forEach(function (el) {
        if (el._dcr) return;
        var D = (el._dcr = setup(el));
        if (D.still) {
          var vals = nextValues(D);
          D.dice.forEach(function (die, i) { var s = SHOW[vals[i]]; pose(die, die.slot * die.node.offsetWidth, 0, s[0], s[1], 0); });
          label(D, vals);
          return;
        }
        D.dice.forEach(function (die) { pose(die, -9999, 0, 0, 0, 0); });
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-dice-roll data-total></div>

<!-- two red dice, rolling every few seconds: -->
<div data-dice-roll data-dice="2" data-total data-loop data-color="#e11d48" data-pip="#fff" style="font-size: 32px"></div>