← 3D & depth

Coin Flip

A gold coin lying on the table leaps into the air, spinning over and over, and lands on heads or tails at random with a little bounce and a wobble. Its shadow shrinks as it rises. Click or tap it to toss it again.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-coin-flip to a <button> with a size.
  3. Put two children in it: heads, then tails. Without them the faces read H and T.

Options

Add any of these attributes to the same element.

data-autoplayMilliseconds: toss it on a timer while it’s in view, until someone tosses it themselves.
data-sizeWidth of the coin in px. Default 42% of the button’s shorter side.
data-colorColour of the coin. Default gold, #f5b83d.
data-durationTime in the air. Default 1300 ms.
data-caption="false"Don’t write “Heads” or “Tails” under it after each toss.

Each toss is a fair 50:50. The result is put on the button as data-result and sent in a coinflip event: el.addEventListener("coinflip", e => e.detail.result).

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Coin Flip: a coin lying on a table is tossed into the air, spins over
    // and over, and lands on heads or tails at random, with a little bounce.
    // Its shadow shrinks as it rises. Click or tap it to flip it. Made with
    // CSS 3D, with a real edge.
    // Usage:
    //   <button type="button" data-coin-flip>
    //     <span>H</span>   <!-- heads -->
    //     <span>T</span>   <!-- tails -->
    //   </button>
    // Without children the faces read H and T.
    // Optional: data-autoplay (ms: flip on a timer while in view, until
    // someone flips it themselves), data-size (px, default 42% of the box's
    // shorter side), data-color (gold, default #f5b83d), data-duration (ms in
    // the air, default 1300), data-caption="false" (no "Heads" / "Tails" line).
    // It fires a "coinflip" event with detail.result ("heads" or "tails").
    var DEFAULTS = { color: '#f5b83d', duration: 1300 };
    var LAYERS = 8; // slices that make the edge

    var css =
      '.cnf{position:relative;display:grid;place-items:center;perspective:1100px;background:none;border:0;padding:0;font:inherit;color:inherit;cursor:pointer;-webkit-tap-highlight-color:transparent}' +
      '.cnf-table{position:relative;width:var(--cnf-s);height:var(--cnf-s);transform-style:preserve-3d;transform:translateY(calc(var(--cnf-s) * .55)) rotateX(55deg)}' +
      '.cnf-shadow{position:absolute;inset:0;border-radius:50%;background:radial-gradient(closest-side,rgba(40,10,70,.45),rgba(40,10,70,0));transform:translateZ(-1px)}' +
      '.cnf-coin{position:absolute;inset:0;transform-style:preserve-3d}' +
      '.cnf-coin>*{position:absolute;inset:0;border-radius:50%;box-sizing:border-box}' +
      '.cnf-face{display:grid;place-items:center;font-weight:800;font-size:calc(var(--cnf-s) * .38);line-height:1;color:var(--cnf-ink);backface-visibility:hidden;-webkit-backface-visibility:hidden;' +
      'background:radial-gradient(circle at 35% 30%,rgba(255,255,255,.7),rgba(255,255,255,0) 45%),var(--cnf-c);' +
      'box-shadow:inset 0 0 0 calc(var(--cnf-s) * .06) var(--cnf-d),inset 0 0 0 calc(var(--cnf-s) * .085) var(--cnf-c),inset 0 0 0 calc(var(--cnf-s) * .095) var(--cnf-d)}' +
      '.cnf-face>*{text-shadow:0 1px 0 rgba(255,255,255,.5),0 -1px 0 rgba(0,0,0,.2)}' +
      '.cnf-slice{background:var(--cnf-d)}' +
      '.cnf-cap{position:absolute;left:0;right:0;bottom:.2em;text-align:center;font-weight:700;font-size:15px;color:var(--text,#222);min-height:1.2em;transition:opacity .25s}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function darken(hex, f) {
      hex = hex.replace('#', '');
      if (hex.length === 3) hex = hex.replace(/./g, '$&$&');
      var n = parseInt(hex, 16);
      return 'rgb(' + [n >> 16 & 255, n >> 8 & 255, n & 255].map(function (v) { return Math.round(v * f); }).join(',') + ')';
    }

    function setup(el) {
      var d = el.dataset;
      var kids = Array.from(el.children);
      el.classList.add('cnf');
      var color = d.color || DEFAULTS.color;
      el.style.setProperty('--cnf-c', color);
      el.style.setProperty('--cnf-d', darken(color, 0.72));
      el.style.setProperty('--cnf-ink', darken(color, 0.5));
      var table = document.createElement('span');
      table.className = 'cnf-table';
      table.setAttribute('aria-hidden', 'true');
      var shadow = document.createElement('span');
      shadow.className = 'cnf-shadow';
      var coin = document.createElement('span');
      coin.className = 'cnf-coin';
      table.appendChild(shadow);
      table.appendChild(coin);

      // Heads on top, the edge as stacked slices, tails underneath.
      var heads = document.createElement('span');
      var tails = document.createElement('span');
      heads.className = tails.className = 'cnf-face';
      heads.appendChild(kids[0] || document.createTextNode('H'));
      tails.appendChild(kids[1] || document.createTextNode('T'));
      var slices = [];
      for (var i = 0; i < LAYERS; i++) {
        var sl = document.createElement('span');
        sl.className = 'cnf-slice';
        coin.appendChild(sl);
        slices.push(sl);
      }
      coin.appendChild(heads);
      coin.appendChild(tails);
      el.appendChild(table);

      var thumb = !!el.closest('[aria-hidden="true"]');
      var cap = null;
      if (d.caption !== 'false' && !thumb) {
        cap = document.createElement('span');
        cap.className = 'cnf-cap';
        cap.setAttribute('aria-live', 'polite');
        el.appendChild(cap);
      }
      if (el.tagName !== 'BUTTON' && !thumb) {
        el.setAttribute('role', 'button');
        if (!el.hasAttribute('tabindex')) el.tabIndex = 0;
      }
      if (!el.hasAttribute('aria-label')) el.setAttribute('aria-label', 'Flip the coin');

      var px = 0, thick = 0;
      var s = { z: 0, spin: 0, wob: 0, busy: false };
      function size() {
        var box = Math.min(el.clientWidth || 200, el.clientHeight || el.clientWidth || 200);
        px = +d.size || Math.round(box * 0.42);
        thick = Math.max(3, px * 0.08);
        el.style.setProperty('--cnf-s', px + 'px');
        heads.style.transform = 'translateZ(' + thick / 2 + 'px)';
        tails.style.transform = 'translateZ(' + -thick / 2 + 'px) rotateX(180deg)';
        slices.forEach(function (sl, i) {
          sl.style.transform = 'translateZ(' + (-thick / 2 + thick * (i + 0.5) / LAYERS).toFixed(2) + 'px)';
        });
        draw();
      }
      function draw() {
        coin.style.transform = 'translateZ(' + (s.z * px).toFixed(2) + 'px) rotateY(' + s.wob.toFixed(2) + 'deg) rotateX(' + s.spin.toFixed(1) + 'deg)';
        var k = 1 / (1 + s.z * 0.9);
        shadow.style.transform = 'translateZ(-1px) scale(' + (0.6 + 0.5 * k).toFixed(3) + ')';
        shadow.style.opacity = (0.25 + 0.75 * k).toFixed(3);
      }
      size();
      if (window.ResizeObserver) new ResizeObserver(size).observe(el);

      var duration = d.duration !== undefined ? +d.duration : DEFAULTS.duration;
      function flip() {
        if (s.busy) return;
        s.busy = true;
        var tailsUp = Math.random() < 0.5;
        // Whole turns plus a half turn when it should land tails up.
        var base = Math.round(s.spin / 360) * 360;
        var to = base + 360 * 4 + (tailsUp ? 180 : 0);
        if (cap) cap.style.opacity = '0';
        if (reduced()) {
          s.spin = to; draw(); done(); return;
        }
        anime.createTimeline({ onUpdate: draw, onComplete: done })
          .add(s, { z: 1.7, duration: duration * 0.48, ease: 'out(2)' }, 0)
          .add(s, { z: 0, duration: duration * 0.52, ease: 'in(2)' }, duration * 0.48)
          .add(s, { spin: to, duration: duration, ease: 'linear' }, 0)
          // A small bounce and a wobble as it settles.
          .add(s, { z: [0, 0.18, 0], duration: 360, ease: 'out(2)' }, duration)
          .add(s, { wob: [0, 9, -5, 2, 0], duration: 700, ease: 'out(2)' }, duration);
        function done() {
          s.busy = false;
          s.spin = to % 360;
          draw();
          var result = tailsUp ? 'tails' : 'heads';
          el.setAttribute('data-result', result);
          if (cap) { cap.textContent = tailsUp ? 'Tails' : 'Heads'; cap.style.opacity = '1'; }
          el.dispatchEvent(new CustomEvent('coinflip', { detail: { result: result }, bubbles: true }));
        }
      }

      var timer = null;
      el.addEventListener('click', function () {
        if (timer) { clearInterval(timer); timer = null; }
        flip();
      });
      el.addEventListener('keydown', function (e) {
        if (el.tagName === 'BUTTON' || (e.key !== 'Enter' && e.key !== ' ')) return;
        e.preventDefault();
        el.click();
      });

      if (d.autoplay !== undefined && d.autoplay !== 'false' && !reduced()) {
        var visible = false, first = true;
        new IntersectionObserver(function (entries) {
          visible = entries[0].isIntersecting;
          if (visible && first) { first = false; setTimeout(flip, 500); }
        }).observe(el);
        timer = setInterval(function () { if (visible) flip(); }, Math.max(duration + 1200, +d.autoplay || 3200));
      }
    }

    function init() {
      document.querySelectorAll('[data-coin-flip]').forEach(function (el) {
        if (el._cnf) return;
        el._cnf = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<button type="button" data-coin-flip style="width: 200px; height: 200px">
  <span>&#9733;</span>
  <span>1</span>
</button>