← E-commerce

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

  1. Paste the snippet just before </head>.
  2. Add an empty element with data-prize-wheel and list the prizes in data-prizes, separated by commas.
  3. To decide the prize yourself (say, from your server), set data-win before the spin, or call animacious.prizeWheel(element, index).

Options

Add any of these attributes to the same element.

data-prizesThe prizes, separated by commas. Default six offers.
data-colorsSegment colours, separated by commas and used in turn.
data-winThe prize to land on, counting from 0. Default random.
data-durationHow long a spin takes. Default 4800 ms.
data-spinThe text on the button in the middle. Default “Spin”.
data-autoplayIt 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.

1 · Snippet
<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, '&amp;').replace(/</g, '&lt;'); }

    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>
2 · Your HTML
<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>