Prize Wheel
Press Spin and the prize wheel whirls round, the pointer clicking past each segment and the rim lights chasing. It slows to a stop on a reward, the winning segment stays lit while the rest dim, and the prize pops up underneath.
How to use it
- Paste the snippet just before
</head>. - Add an empty element with
data-prize-wheeland list the prizes indata-prizes, separated by commas. - To decide the prize yourself (say, from your server), set
data-winbefore the spin, or callanimacious.prizeWheel(element, index).
Options
Add any of these attributes to the same element.
data-prizes | The prizes, separated by commas. Default six offers. |
data-colors | Segment colours, separated by commas and used in turn. |
data-win | The prize to land on, counting from 0. Default random. |
data-duration | How long a spin takes. Default 4800 ms. |
data-spin | The text on the button in the middle. Default “Spin”. |
data-autoplay | It spins itself every few seconds while in view, until someone presses Spin. Handy for demos. |
It fires a prize event with e.detail.prize and e.detail.index, and the prize is announced to screen readers. Size it with width; the text scales with font-size.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Prize Wheel: a wheel of prizes spins, the pointer clicking past each
// segment, slows to a stop and lands on a reward. The winning segment
// stays lit and the prize pops up underneath.
// Usage: <div data-prize-wheel data-prizes="10% off, Free delivery, £5 off, 15% off"></div>
// Optional: data-prizes (comma-separated, default six offers),
// data-colors (comma-separated segment colours, used in turn),
// data-win (the prize to land on, counting from 0; default random),
// data-duration (spin length in ms, default 4800), data-spin (button text,
// default "Spin"), data-autoplay (it spins itself every few seconds while
// in view, until someone presses Spin; handy for demos).
// Fires a "prize" event with e.detail.prize and e.detail.index.
// Script use: animacious.prizeWheel(el, index) spins it.
var DEFAULTS = {
prizes: '10% off,Free delivery,£5 off,15% off,20% off,Mystery gift',
colors: '#6a0dad,#f59e0b,#ec4899,#0ea5e9,#16a34a,#f43f5e',
duration: 4800,
spin: 'Spin'
};
var css =
'.pzw{position:relative;width:min(20em,100%);aspect-ratio:1;display:block}' +
'.pzw-disc{position:absolute;inset:4%;border-radius:50%;box-shadow:0 .6em 1.6em rgba(15,15,30,.18)}' +
'.pzw-disc svg,.pzw-rim svg{display:block;width:100%;height:100%;overflow:visible}' +
'.pzw-seg{transition:opacity .35s}' +
'.pzw-dim .pzw-seg{opacity:.4}.pzw-dim .pzw-seg.pzw-win{opacity:1}' +
'.pzw-rim{position:absolute;inset:0;pointer-events:none}' +
'.pzw-bulb{transition:fill .2s}' +
'.pzw-pointer{position:absolute;left:50%;top:-1%;width:13%;margin-left:-6.5%;transform-origin:50% 20%;filter:drop-shadow(0 .15em .2em rgba(0,0,0,.3))}' +
'.pzw-pointer svg{display:block;width:100%}' +
'.pzw-hub{position:absolute;left:50%;top:50%;width:24%;height:24%;margin:-12% 0 0 -12%;display:grid;place-items:center;border:0;border-radius:50%;padding:0;' +
'background:#fff;color:#1a1a2e;font:inherit;font-size:max(10px,.8em);font-weight:800;letter-spacing:.02em;text-transform:uppercase;cursor:pointer;' +
'box-shadow:0 0 0 .25em #1a1a2e,0 .3em .8em rgba(0,0,0,.3);-webkit-tap-highlight-color:transparent}' +
'span.pzw-hub{cursor:default}' +
'.pzw-hub:disabled{cursor:default}' +
'.pzw-result{position:absolute;left:50%;bottom:-2%;transform:translateX(-50%);white-space:nowrap;padding:.35em .9em;border-radius:999px;' +
'background:#1a1a2e;color:#fff;font-weight:700;font-size:max(11px,.85em);box-shadow:0 .3em 1em rgba(0,0,0,.25);opacity:0;pointer-events:none}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function on(v) { return v !== undefined && v !== 'false'; }
function list(v) { return v.split(',').map(function (x) { return x.trim(); }).filter(Boolean); }
function pt(r, deg) {
var a = (deg * Math.PI) / 180;
return (r * Math.sin(a)).toFixed(2) + ' ' + (-r * Math.cos(a)).toFixed(2);
}
function esc(t) { return t.replace(/&/g, '&').replace(/</g, '<'); }
function wheelSvg(prizes, colors) {
var n = prizes.length, a = 360 / n, out = '';
var fs = n > 8 ? 9 : 11;
prizes.forEach(function (p, i) {
var s = i * a - a / 2, e = i * a + a / 2;
out += '<g class="pzw-seg"><path d="M0 0L' + pt(100, s) + 'A100 100 0 0 1 ' + pt(100, e) + 'Z" fill="' + colors[i % colors.length] + '"/>' +
'<text transform="rotate(' + (i * a - 90) + ') translate(62 0)" x="0" y="0" text-anchor="middle" dominant-baseline="central" fill="#fff" ' +
'font-size="' + (p.length > 11 ? fs - 2 : fs) + '" font-weight="700" font-family="system-ui,-apple-system,Segoe UI,sans-serif">' + esc(p) + '</text></g>';
});
return '<svg viewBox="-100 -100 200 200" aria-hidden="true"><defs><linearGradient id="pzw-sheen" x1="0" y1="0" x2="0" y2="1">' +
'<stop offset="0" stop-color="#fff" stop-opacity=".22"/><stop offset=".5" stop-color="#fff" stop-opacity="0"/></linearGradient></defs>' +
'<g class="pzw-spin">' + out + '</g><circle r="100" fill="url(#pzw-sheen)"/></svg>';
}
function rimSvg(n) {
var bulbs = '', count = Math.max(12, n * 2);
for (var i = 0; i < count; i++) {
bulbs += '<circle class="pzw-bulb" cx="' + pt(104, (360 / count) * i).replace(' ', '" cy="') + '" r="3.4" fill="#fde68a"/>';
}
return '<svg viewBox="-115 -115 230 230" aria-hidden="true"><circle r="104" fill="none" stroke="#1a1a2e" stroke-width="12"/>' + bulbs + '</svg>';
}
function setup(el) {
var d = el.dataset;
el.classList.add('pzw');
var prizes = list(d.prizes || DEFAULTS.prizes);
var colors = list(d.colors || DEFAULTS.colors);
var inert = !!el.closest('a,[aria-hidden="true"]');
el.innerHTML = '';
var disc = document.createElement('div');
disc.className = 'pzw-disc';
disc.innerHTML = wheelSvg(prizes, colors);
var rim = document.createElement('div');
rim.className = 'pzw-rim';
rim.innerHTML = rimSvg(prizes.length);
var pointer = document.createElement('div');
pointer.className = 'pzw-pointer';
pointer.setAttribute('aria-hidden', 'true');
pointer.innerHTML = '<svg viewBox="0 0 30 40"><path d="M15 39L2 10a13 13 0 1 1 26 0z" fill="#fff" stroke="#1a1a2e" stroke-width="3" stroke-linejoin="round"/><circle cx="15" cy="12" r="4" fill="#1a1a2e"/></svg>';
var hub = document.createElement(inert ? 'span' : 'button');
hub.className = 'pzw-hub';
hub.textContent = d.spin || DEFAULTS.spin;
if (!inert) {
hub.type = 'button';
hub.setAttribute('aria-label', 'Spin the prize wheel');
}
var result = document.createElement('div');
result.className = 'pzw-result';
result.setAttribute('role', 'status');
[disc, rim, pointer, hub, result].forEach(function (x) { el.appendChild(x); });
var s = el._pzw = {
el: el, disc: disc, spinner: disc.querySelector('.pzw-spin'), pointer: pointer, hub: hub, result: result, prizes: prizes,
segs: disc.querySelectorAll('.pzw-seg'), bulbs: rim.querySelectorAll('.pzw-bulb'),
rot: 0, busy: false, visible: false
};
if (!inert) {
hub.addEventListener('click', function (e) {
if (e.isTrusted) s.user = true;
spin(el);
});
}
if (on(d.autoplay) && !reduced()) {
var timer = null;
new IntersectionObserver(function (entries) {
s.visible = entries[0].isIntersecting;
clearInterval(timer);
if (!s.visible) return;
var tick = function () { if (!s.user && !s.busy) spin(el); };
setTimeout(tick, 600);
timer = setInterval(tick, (+d.duration || DEFAULTS.duration) + 3200);
}, { threshold: 0.3 }).observe(el);
}
}
function bulbs(s, lit) {
s.bulbs.forEach(function (b, i) { b.style.fill = (i % 2 === lit % 2) ? '#fde68a' : '#f59e0b'; });
}
function spin(el, index) {
var s = el._pzw, d = el.dataset;
if (!s || s.busy) return;
var n = s.prizes.length, a = 360 / n;
var win = index !== undefined ? index : (d.win !== undefined ? +d.win : Math.floor(Math.random() * n));
win = ((win % n) + n) % n;
s.busy = true;
if (s.hub.tagName === 'BUTTON') s.hub.disabled = true;
el.classList.remove('pzw-dim');
s.segs.forEach(function (g) { g.classList.remove('pzw-win'); });
anime.animate(s.result, { opacity: 0, y: 0, duration: 200 });
// Land with segment `win` under the pointer, a little off-centre so it
// doesn't look rigged.
var jitter = (Math.random() - 0.5) * a * 0.6;
var target = -win * a + jitter;
var delta = ((target - s.rot) % 360 + 360) % 360;
var dur = reduced() ? 0 : (+d.duration || DEFAULTS.duration);
var end = s.rot + delta + (dur ? 360 * 5 : 0);
var st = { r: s.rot }, lastSeg = Math.floor((s.rot + a / 2) / a), blink = 0;
function finish() {
s.rot = end % 360;
s.spinner.setAttribute('transform', 'rotate(' + s.rot + ')');
s.busy = false;
if (s.hub.tagName === 'BUTTON') s.hub.disabled = false;
s.segs[win].classList.add('pzw-win');
el.classList.add('pzw-dim');
s.result.textContent = s.prizes[win];
if (dur) {
anime.animate(s.result, { opacity: [0, 1], y: ['.6em', 0], scale: [0.7, 1], duration: 520, ease: 'outBack(2)' });
var k = 0, t = setInterval(function () { bulbs(s, k++); if (k > 8) clearInterval(t); }, 140);
} else {
anime.utils.set(s.result, { opacity: 1 });
}
el.dispatchEvent(new CustomEvent('prize', { bubbles: true, detail: { prize: s.prizes[win], index: win } }));
}
if (!dur) { finish(); return; }
anime.animate(st, {
r: end,
duration: dur,
ease: 'out(4)',
onUpdate: function () {
s.spinner.setAttribute('transform', 'rotate(' + st.r.toFixed(2) + ')');
var seg = Math.floor((st.r + a / 2) / a);
if (seg !== lastSeg) {
lastSeg = seg;
anime.animate(s.pointer, { rotate: [-22, 0], duration: 160, ease: 'out(2)' });
if (++blink % 2) bulbs(s, blink >> 1);
}
},
onComplete: finish
});
}
window.animacious = window.animacious || {};
window.animacious.prizeWheel = spin;
function init() {
document.querySelectorAll('[data-prize-wheel]').forEach(function (el) {
if (el._pzw) return;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-prize-wheel data-prizes="10% off, Free delivery, £5 off, 15% off, 20% off, Mystery gift"></div>
<script>
document.querySelector('[data-prize-wheel]')
.addEventListener('prize', (e) => applyOffer(e.detail.prize));
</script>