← Broadcast

Tug-of-war Bar

Two colours push against each other along a bar, for stream polls, predictions and versus battles. Stripes on each side flow towards the front where they meet, the front springs to each new share with a spray of sparks, and arrows mark the halfway line. Each side shows its name and share.

Who takes the final?

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-tug-of-war-bar to an empty element, with the side names in data-left and data-right. It fills the width of its container; size it with font-size.
  3. Call animacious.tugOfWarBar(el, 62) to move the front to 62% for the left side.

Options

Add any of these attributes to the same element.

data-left, data-rightNames for each side.
data-valueThe left side’s starting share, from 0 to 100. Default 50.
data-left-color, data-right-colorSide colours. Default #a855f7 and #f59e0b.
data-autoplayThe sides push back and forth on their own, for demos.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Tug-of-war Bar: two colours push against each other along a bar, for
    // stream polls, predictions and versus battles. Stripes flow towards the
    // front where they meet, the front springs to each new share with a flash
    // of sparks, and a marker shows the halfway line. Each side shows its name
    // and share.
    // Usage: <div data-tug-of-war-bar data-left="Cats" data-right="Dogs" data-value="55"></div>
    // Size it with font-size; it fills the width of its container.
    // Optional: data-left, data-right (side names), data-value (left side's
    // share, 0 to 100, default 50), data-left-color (default #a855f7),
    // data-right-color (default #f59e0b), data-autoplay (the sides push back
    // and forth on their own, for demos).
    // Script use: animacious.tugOfWarBar(el, 62) moves the front to 62% left.
    var DEFAULTS = { value: 50, leftColor: '#a855f7', rightColor: '#f59e0b' };

    var css =
      '.tow{display:block;width:100%;line-height:1.2;color:#fff;--tow-l:' + DEFAULTS.leftColor + ';--tow-r:' + DEFAULTS.rightColor + '}' +
      '.tow-head{display:flex;justify-content:space-between;align-items:baseline;gap:1em;margin-bottom:0.45em;font-weight:800}' +
      '.tow-side{display:flex;align-items:baseline;gap:0.45em;min-width:0;white-space:nowrap}' +
      '.tow-side-r{flex-direction:row-reverse}' +
      '.tow-name{font-size:0.85em;letter-spacing:0.06em;text-transform:uppercase;opacity:.85;overflow:hidden;text-overflow:ellipsis}' +
      '.tow-pct{font-size:1.35em;font-variant-numeric:tabular-nums}' +
      '.tow-side-l .tow-pct{color:color-mix(in srgb,var(--tow-l) 55%,#fff)}' +
      '.tow-side-r .tow-pct{color:color-mix(in srgb,var(--tow-r) 55%,#fff)}' +
      '.tow-wrap{position:relative;padding:0.45em 0}' +
      '.tow-bar{position:relative;height:1.4em;border-radius:999px;overflow:hidden;background:var(--tow-r);box-shadow:inset 0 0 0 1px rgba(255,255,255,.15),0 0.3em 1em rgba(0,0,0,.35)}' +
      '.tow-l,.tow-r{position:absolute;top:0;bottom:0;background-size:1.6em 100%}' +
      '.tow-l{left:0;width:50%;background-color:var(--tow-l);background-image:repeating-linear-gradient(115deg,rgba(255,255,255,.16) 0 0.4em,transparent 0.4em 0.8em);animation:tow-push-l 0.9s linear infinite}' +
      '.tow-r{right:0;left:50%;background-color:var(--tow-r);background-image:repeating-linear-gradient(65deg,rgba(255,255,255,.16) 0 0.4em,transparent 0.4em 0.8em);animation:tow-push-r 0.9s linear infinite}' +
      '@keyframes tow-push-l{to{background-position:1.6em 0}}' +
      '@keyframes tow-push-r{to{background-position:-1.6em 0}}' +
      '.tow-still .tow-l,.tow-still .tow-r{animation-play-state:paused}' +
      '.tow-bar::after{content:"";position:absolute;inset:0 0 50% 0;background:linear-gradient(rgba(255,255,255,.28),transparent);pointer-events:none}' +
      '.tow-front{position:absolute;top:0.2em;bottom:0.2em;left:50%;width:0.3em;margin-left:-0.15em;border-radius:1em;background:#fff;box-shadow:0 0 0.5em #fff,0 0 1.2em rgba(255,255,255,.7)}' +
      '.tow-mid{position:absolute;left:50%;top:0;bottom:0;width:2px;margin-left:-1px;background:rgba(255,255,255,.75);border-radius:2px}' +
      '.tow-mid::before,.tow-mid::after{content:"";position:absolute;left:50%;width:0;height:0;margin-left:-0.3em;border:0.3em solid transparent}' +
      '.tow-mid::before{top:-0.1em;border-top-color:#fff}' +
      '.tow-mid::after{bottom:-0.1em;border-bottom-color:#fff}' +
      '.tow-spark{position:absolute;top:50%;width:0.3em;height:0.3em;margin:-0.15em;border-radius:50%;background:#fff;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 clamp(v) { return Math.max(0, Math.min(100, v)); }

    function setup(el) {
      var d = el.dataset;
      var v = clamp(d.value !== undefined && d.value !== '' ? +d.value : DEFAULTS.value);
      el.classList.add('tow');
      el.style.setProperty('--tow-l', d.leftColor || DEFAULTS.leftColor);
      el.style.setProperty('--tow-r', d.rightColor || DEFAULTS.rightColor);
      el.setAttribute('role', 'meter');
      el.setAttribute('aria-valuemin', '0');
      el.setAttribute('aria-valuemax', '100');
      el.innerHTML =
        '<div class="tow-head" aria-hidden="true">' +
          '<span class="tow-side tow-side-l"><span class="tow-pct"></span><span class="tow-name"></span></span>' +
          '<span class="tow-side tow-side-r"><span class="tow-pct"></span><span class="tow-name"></span></span>' +
        '</div>' +
        '<div class="tow-wrap" aria-hidden="true"><div class="tow-bar"><span class="tow-l"></span><span class="tow-r"></span><span class="tow-front"></span></div><span class="tow-mid"></span></div>';
      var names = el.querySelectorAll('.tow-name'), pcts = el.querySelectorAll('.tow-pct');
      names[0].textContent = d.left || '';
      names[1].textContent = d.right || '';
      var s = {
        v: { value: v }, target: v,
        l: el.querySelector('.tow-l'), r: el.querySelector('.tow-r'),
        front: el.querySelector('.tow-front'), wrap: el.querySelector('.tow-wrap'),
        pl: pcts[0], pr: pcts[1], sparks: []
      };
      for (var i = 0; i < 8; i++) {
        var sp = document.createElement('span');
        sp.className = 'tow-spark';
        s.wrap.appendChild(sp);
        s.sparks.push(sp);
      }
      el._tow = s;
      render(el);
      if (reduced()) el.classList.add('tow-still');
    }

    function render(el) {
      var s = el._tow, v = clamp(s.v.value);
      s.l.style.width = v + '%';
      s.r.style.left = v + '%';
      s.front.style.left = v + '%';
      var shown = Math.round(clamp(s.target));
      s.pl.textContent = Math.round(v) + '%';
      s.pr.textContent = (100 - Math.round(v)) + '%';
      el.setAttribute('aria-valuenow', String(shown));
      var d = el.dataset;
      el.setAttribute('aria-label', (d.left || 'Left') + ' ' + shown + '%, ' + (d.right || 'Right') + ' ' + (100 - shown) + '%');
    }

    function push(el, to) {
      var s = el._tow;
      to = clamp(to);
      var from = s.v.value;
      s.target = to;
      if (reduced()) { s.v.value = to; render(el); return; }
      if (s.anim) s.anim.pause();
      var big = Math.abs(to - from);
      s.anim = anime.animate(s.v, {
        value: to,
        duration: 900 + big * 15,
        ease: 'outElastic(1, .55)',
        onUpdate: function () { render(el); }
      });
      // Sparks fly back from the front, towards the side that is losing ground.
      var dir = to > from ? 1 : -1;
      s.sparks.forEach(function (sp, i) {
        anime.utils.set(sp, { left: to + '%', x: 0, y: 0, opacity: 0, scale: 1 });
        anime.animate(sp, {
          x: (dir * anime.utils.random(8, 30) / 10) + 'em',
          y: (anime.utils.random(-14, 14) / 10) + 'em',
          scale: [1.2, 0],
          opacity: [{ to: 1, duration: 40 }, { to: 0, duration: 460 }],
          duration: 500,
          delay: 150 + i * 12,
          ease: 'out(3)'
        });
      });
      anime.animate(s.front, { scaleX: [2.4, 1], duration: 500, ease: 'out(3)' });
    }

    // Autoplay: a random walk with the odd big shove, staying between 15 and 85.
    function auto(el) {
      var s = el._tow;
      clearTimeout(s.timer);
      if (!s.visible) return;
      var v = s.target;
      var step = Math.random() < 0.25 ? anime.utils.random(14, 24) : anime.utils.random(4, 10);
      var dir = v > 68 ? -1 : v < 32 ? 1 : (Math.random() < 0.5 ? -1 : 1);
      push(el, Math.max(15, Math.min(85, v + dir * step)));
      s.timer = setTimeout(function () { auto(el); }, anime.utils.random(1300, 2100));
    }

    window.animacious = window.animacious || {};
    window.animacious.tugOfWarBar = function (el, value) {
      if (!el || !el._tow) return;
      push(el, +value);
    };

    function init() {
      // Stripes and autoplay stop while the bar is off screen.
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var el = e.target, s = el._tow;
          s.visible = e.isIntersecting;
          el.classList.toggle('tow-still', !e.isIntersecting);
          if (!e.isIntersecting) { clearTimeout(s.timer); return; }
          if (el.dataset.autoplay !== undefined && el.dataset.autoplay !== 'false') {
            s.timer = setTimeout(function () { auto(el); }, 700);
          }
        });
      }, { threshold: 0.2 });
      document.querySelectorAll('[data-tug-of-war-bar]').forEach(function (el) {
        if (el._tow) return;
        setup(el);
        if (!reduced()) io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-tug-of-war-bar data-left="Cats" data-right="Dogs"
     data-value="50"></div>

<script>
  // when the votes change:
  animacious.tugOfWarBar(document.querySelector('[data-tug-of-war-bar]'), 58);
</script>