← Numbers

Lottery Balls

Numbered balls tumble in a spinning glass drum, tossed about by jets of air and knocking into each other. One at a time a ball rises to the top and pops out, spinning into its place in the tray beside the drum.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-lottery-balls to an empty element for six random numbers, or give it the numbers to draw: data-lottery-balls="7,19,23,31,42,48". Size it with font-size; the drum is 8em across.

Options

Add any of these attributes to the same element.

data-drawHow many numbers to draw when they’re random. Default 6.
data-maxThe highest number. Default 49.
data-ballsHow many balls tumble in the drum. Default 18.
data-intervalGap between balls popping out. Default 1300 ms.
data-loopPut the balls back and draw again.
data-holdHow long the result stays before looping. Default 3000 ms.
data-colorsBall colours for 1–9, 10–19, 20–29 and so on. Default yellow, blue, red, green, purple.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Lottery Balls: numbered balls tumble in a spinning glass drum, blown
    // about by the air, and pop out one at a time into a tray beside it.
    // Usage: <div data-lottery-balls></div>   (draws 6 random numbers from 1-49)
    //        <div data-lottery-balls="7,19,23,31,42,48"></div>   (these numbers)
    // Optional: data-draw (how many to draw when random, default 6), data-max
    // (highest number, default 49), data-balls (balls in the drum, default 18),
    // data-interval (ms between balls, default 1300), data-loop (put the balls
    // back and draw again), data-hold (ms the result stays before looping,
    // default 3000), data-colors (comma-separated colours for 1-9, 10-19, 20-29
    // and so on, default yellow, blue, red, green, purple).
    // Size it with font-size: the drum is 8em across.
    var DEFAULTS = { draw: 6, max: 49, balls: 18, interval: 1300, hold: 3000, colors: '#f5b700,#2f7bf5,#ef4444,#22b35e,#9b4dea' };

    var css =
      '.ltb{display:inline-flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:1em 1.5em;max-width:100%}' +
      '.ltb-machine{position:relative;flex:none;width:8em;padding-bottom:1.1em}' +
      // The stand under the drum and the chute on top.
      '.ltb-machine::before{content:"";position:absolute;left:50%;bottom:0;width:4.2em;height:1.5em;margin-left:-2.1em;border-radius:.3em .3em .5em .5em;' +
      'background:linear-gradient(#5b5b66,#2c2c33);clip-path:polygon(22% 0,78% 0,100% 100%,0 100%)}' +
      '.ltb-machine::after{content:"";position:absolute;left:50%;top:-.55em;width:1.6em;height:.9em;margin-left:-.8em;border-radius:.3em .3em 0 0;' +
      'background:linear-gradient(90deg,#c9c9d3,#f4f4f8 45%,#a9a9b6);box-shadow:inset 0 -.12em 0 rgba(0,0,0,.15)}' +
      '.ltb-drum{position:relative;width:8em;height:8em;border-radius:50%;z-index:1;' +
      'background:radial-gradient(circle at 50% 50%,rgba(255,255,255,.05) 55%,rgba(120,140,170,.22) 100%),rgba(220,232,245,.35);' +
      'box-shadow:inset 0 0 0 .14em rgba(160,170,190,.9),inset 0 0 1.2em rgba(90,110,140,.25),0 .4em 1em rgba(30,30,60,.15)}' +
      '.ltb-drum::after{content:"";position:absolute;inset:6% 10% auto auto;width:38%;height:24%;border-radius:50%;transform:rotate(30deg);' +
      'background:linear-gradient(rgba(255,255,255,.75),rgba(255,255,255,0));pointer-events:none;z-index:3}' +
      '.ltb-paddle{position:absolute;left:50%;top:50%;width:84%;height:.16em;margin:-.08em 0 0 -42%;border-radius:.1em;background:rgba(130,140,160,.55)}' +
      '.ltb-paddle::after{content:"";position:absolute;inset:0;border-radius:inherit;background:inherit;transform:rotate(90deg)}' +
      '.ltb-hub{position:absolute;left:50%;top:50%;width:.5em;height:.5em;margin:-.25em;border-radius:50%;background:#8a8f9e}' +
      '.ltb-ball{position:absolute;left:0;top:0;width:1.7em;height:1.7em;border-radius:50%;display:grid;place-items:center;transform-origin:0 0;z-index:2;' +
      'background:radial-gradient(circle at 34% 28%,rgba(255,255,255,.85),rgba(255,255,255,0) 32%),radial-gradient(circle at 50% 50%,transparent 55%,rgba(0,0,0,.28)),var(--ltb-c);' +
      'box-shadow:0 .08em .16em rgba(0,0,0,.25)}' +
      '.ltb-ball b{display:grid;place-items:center;width:58%;height:58%;border-radius:50%;background:#fff;color:#1a1a22;font:800 .62em/1 system-ui,sans-serif;letter-spacing:-.04em;font-variant-numeric:tabular-nums}' +
      '.ltb-drum .ltb-ball{font-size:.74em}' +
      '.ltb-tray{display:flex;flex-wrap:wrap;justify-content:center;gap:.4em;max-width:100%}' +
      '.ltb-slot{position:relative;width:1.7em;height:1.7em;border-radius:50%;box-shadow:inset 0 .1em .25em rgba(0,0,0,.18);background:rgba(127,127,140,.14)}' +
      '.ltb-slot .ltb-ball{z-index:4}';
    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 setup(el) {
      var d = el.dataset;
      var fixed = (el.getAttribute('data-lottery-balls') || '').split(/[,|\s]+/).filter(Boolean).map(Number);
      var L = {
        el: el, fixed: fixed.length ? fixed : null, draw: +d.draw || DEFAULTS.draw, max: +d.max || DEFAULTS.max,
        count: +d.balls || DEFAULTS.balls, interval: +d.interval || DEFAULTS.interval, hold: +d.hold || DEFAULTS.hold,
        colors: (d.colors || DEFAULTS.colors).split(','), loop: d.loop !== undefined && d.loop !== 'false',
        still: reduced(), visible: false, raf: 0, timer: 0, last: 0, angle: 0, balls: [], picks: [], drawn: 0, busy: false
      };
      el.classList.add('ltb');
      var draws = L.fixed ? L.fixed.length : L.draw;
      var slots = '';
      for (var i = 0; i < draws; i++) slots += '<span class="ltb-slot"></span>';
      el.innerHTML = '<div class="ltb-machine" aria-hidden="true"><div class="ltb-drum"><span class="ltb-paddle"></span><span class="ltb-hub"></span></div></div>' +
        '<div class="ltb-tray" aria-hidden="true">' + slots + '</div>';
      L.drum = el.querySelector('.ltb-drum');
      L.paddle = el.querySelector('.ltb-paddle');
      L.slots = [].slice.call(el.querySelectorAll('.ltb-slot'));
      el.setAttribute('role', 'img');
      fill(L);
      return L;
    }

    function colour(L, n) { return L.colors[Math.min(L.colors.length - 1, Math.floor(n / 10))]; }

    // Chooses this round's numbers and fills the drum with them plus others.
    function fill(L) {
      var pool = [], i;
      for (i = 1; i <= L.max; i++) pool.push(i);
      pool.sort(function () { return Math.random() - 0.5; });
      L.picks = L.fixed ? L.fixed.slice() : pool.slice(0, L.draw);
      var nums = L.picks.slice();
      for (i = 0; nums.length < Math.max(L.count, L.picks.length) && i < pool.length; i++) if (nums.indexOf(pool[i]) < 0) nums.push(pool[i]);
      L.balls.forEach(function (b) { b.node.remove(); });
      L.balls = nums.map(function (n) {
        var node = document.createElement('span');
        node.className = 'ltb-ball';
        node.style.setProperty('--ltb-c', colour(L, n));
        node.innerHTML = '<b>' + n + '</b>';
        L.drum.appendChild(node);
        return { n: n, node: node, x: rand(-1, 1), y: rand(0.2, 0.9), vx: 0, vy: 0, free: true };
      });
      L.drawn = 0;
      L.el.setAttribute('aria-label', 'Lottery draw: ' + L.picks.join(', '));
      measure(L);
      // Start them resting in a heap at the bottom of the drum.
      L.balls.forEach(function (b, k) {
        var a = Math.PI / 2 + (k % 7 - 3) * 0.28, rr = L.R - L.r - Math.floor(k / 7) * L.r * 1.9;
        b.x = Math.cos(a) * rr; b.y = Math.sin(a) * rr;
      });
      place(L);
    }

    function measure(L) {
      var R = L.drum.clientWidth / 2, r = L.balls.length ? L.balls[0].node.offsetWidth / 2 : 10;
      if (L.R && R !== L.R) L.balls.forEach(function (b) { b.x *= R / L.R; b.y *= R / L.R; });
      L.R = R; L.r = r;
    }

    function place(L) {
      L.balls.forEach(function (b) {
        if (b.free) b.node.style.transform = 'translate(' + (L.R + b.x - L.r).toFixed(1) + 'px,' + (L.R + b.y - L.r).toFixed(1) + 'px)';
      });
      L.paddle.style.transform = 'rotate(' + L.angle + 'rad)';
    }

    // Air-mixer physics: gravity, a spinning wall, jets of air from below and
    // balls knocking into each other.
    function step(L, dt) {
      var R = L.R, r = L.r, lim = R - r - R * 0.03, spin = 2.2, free = L.balls.filter(function (b) { return b.free; });
      L.angle += spin * dt;
      free.forEach(function (b) {
        b.vy += R * 3.2 * dt;
        if (b.y > R * 0.15 && Math.random() < 6 * dt) { b.vy -= rand(R * 1.4, R * 3.2); b.vx += rand(-R, R); }
        b.vx *= 1 - 0.4 * dt; b.vy *= 1 - 0.4 * dt;
        b.x += b.vx * dt; b.y += b.vy * dt;
        var dist = Math.sqrt(b.x * b.x + b.y * b.y);
        if (dist > lim) {
          var nx = b.x / dist, ny = b.y / dist, vn = b.vx * nx + b.vy * ny;
          b.x = nx * lim; b.y = ny * lim;
          if (vn > 0) { b.vx -= 1.75 * vn * nx; b.vy -= 1.75 * vn * ny; }
          var tx = -ny, ty = nx, vt = b.vx * tx + b.vy * ty, pull = (spin * R - vt) * 0.25;
          b.vx += tx * pull; b.vy += ty * pull;
        }
        var sp = Math.sqrt(b.vx * b.vx + b.vy * b.vy), cap = R * 5;
        if (sp > cap) { b.vx *= cap / sp; b.vy *= cap / sp; }
      });
      for (var i = 0; i < free.length; i++) {
        for (var j = i + 1; j < free.length; j++) {
          var a = free[i], c = free[j], dx = c.x - a.x, dy = c.y - a.y, dd = dx * dx + dy * dy, min = 2 * r;
          if (dd >= min * min || dd === 0) continue;
          var dist2 = Math.sqrt(dd), nx2 = dx / dist2, ny2 = dy / dist2, push = (min - dist2) / 2;
          a.x -= nx2 * push; a.y -= ny2 * push; c.x += nx2 * push; c.y += ny2 * push;
          var rel = (c.vx - a.vx) * nx2 + (c.vy - a.vy) * ny2;
          if (rel < 0) { var imp = -rel * 0.95; a.vx -= nx2 * imp; a.vy -= ny2 * imp; c.vx += nx2 * imp; c.vy += ny2 * imp; }
        }
      }
    }

    function frame(L, now) {
      if (!L.visible) return;
      var dt = Math.min(0.033, (now - (L.last || now)) / 1000);
      L.last = now;
      // Small sub-steps keep fast balls from slipping through each other.
      step(L, dt / 2); step(L, dt / 2);
      place(L);
      L.raf = requestAnimationFrame(function (t) { frame(L, t); });
    }

    // Lifts the next chosen ball to the chute, then flies it to its slot.
    function drawOne(L) {
      if (!L.visible) return;
      if (L.drawn >= L.picks.length) {
        if (L.loop) L.timer = setTimeout(function () { reset(L); }, L.hold);
        return;
      }
      var n = L.picks[L.drawn], b = L.balls.filter(function (x) { return x.n === n; })[0], slot = L.slots[L.drawn];
      L.drawn++;
      b.free = false;
      b.node.style.zIndex = 5;
      var p = { x: b.x, y: b.y }, top = -(L.R - L.r * 0.4);
      anime.animate(p, {
        x: 0, y: top, duration: 420, ease: 'inOut(2)',
        onUpdate: function () { b.node.style.transform = 'translate(' + (L.R + p.x - L.r) + 'px,' + (L.R + p.y - L.r) + 'px)'; },
        onComplete: function () {
          var from = b.node.getBoundingClientRect();
          slot.appendChild(b.node);
          b.node.style.transform = '';
          b.node.style.zIndex = '';
          var to = b.node.getBoundingClientRect();
          anime.animate(b.node, {
            x: [from.left - to.left, 0], y: [from.top - to.top, 0], scale: [from.width / to.width, 1],
            duration: 700, ease: 'inOut(3)'
          });
          anime.animate(b.node.firstChild, { rotate: [-300, 0], duration: 760, ease: 'out(3)' });
        }
      });
      L.timer = setTimeout(function () { drawOne(L); }, L.interval);
    }

    function reset(L) {
      if (!L.visible) return;
      var tray = L.balls.filter(function (b) { return !b.free; }).map(function (b) { return b.node; });
      anime.animate(tray, {
        scale: 0, opacity: 0, duration: 380, delay: anime.stagger(50), ease: 'in(2)',
        onComplete: function () {
          fill(L);
          L.balls.forEach(function (b) { b.vy = -rand(L.R, L.R * 2.5); b.vx = rand(-L.R, L.R); });
          L.timer = setTimeout(function () { drawOne(L); }, 1400);
        }
      });
    }

    function start(L) {
      L.visible = true;
      measure(L);
      L.last = 0;
      cancelAnimationFrame(L.raf);
      L.raf = requestAnimationFrame(function (t) { frame(L, t); });
      if (!L.started) {
        L.started = true;
        L.timer = setTimeout(function () { drawOne(L); }, 1500);
      } else if (!L.timer) {
        L.timer = setTimeout(function () { L.drawn >= L.picks.length ? (L.loop ? reset(L) : 0) : drawOne(L); }, 800);
      }
    }

    function stop(L) {
      L.visible = false;
      cancelAnimationFrame(L.raf);
      clearTimeout(L.timer);
      L.timer = 0;
    }

    // Reduced motion: the result in the tray, the rest of the balls at rest.
    function still(L) {
      L.picks.forEach(function (n, k) {
        var b = L.balls.filter(function (x) { return x.n === n; })[0];
        b.free = false;
        b.node.style.transform = '';
        L.slots[k].appendChild(b.node);
      });
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var L = e.target._ltb;
          if (e.isIntersecting) start(L); else stop(L);
        });
      }, { threshold: 0.2 });
      document.querySelectorAll('[data-lottery-balls]').forEach(function (el) {
        if (el._ltb) return;
        var L = (el._ltb = setup(el));
        if (L.still) { still(L); return; }
        io.observe(el);
        if (window.ResizeObserver) new ResizeObserver(function () { measure(L); place(L); }).observe(L.drum);
      });
    }

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

<!-- draw these numbers, again and again: -->
<div data-lottery-balls="7,19,23,31,42,48" data-loop style="font-size: 20px"></div>