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
- Paste the snippet just before
</head>. - Add
data-lottery-ballsto 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 withfont-size; the drum is 8em across.
Options
Add any of these attributes to the same element.
data-draw | How many numbers to draw when they’re random. Default 6. |
data-max | The highest number. Default 49. |
data-balls | How many balls tumble in the drum. Default 18. |
data-interval | Gap between balls popping out. Default 1300 ms. |
data-loop | Put the balls back and draw again. |
data-hold | How long the result stays before looping. Default 3000 ms. |
data-colors | Ball colours for 1–9, 10–19, 20–29 and so on. Default yellow, blue, red, green, purple. |
<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><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>