← Broadcast

Score Bug

A compact scoreboard for the corner of a sports stream: team colours, short names, the score and a running match clock. It slides in piece by piece, and when a team scores their number rolls over, their side flashes and a “Goal” tab drops out underneath.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-score-bug to an empty element, with data-home and data-away set to short team names. Size it with font-size.
  3. Call animacious.scoreBug(el, 'home') when the home team scores (or 'away').

Options

Add any of these attributes to the same element.

data-home-color, data-away-colorTeam colours. Default #ef4444 and #3b82f6.
data-home-score, data-away-scoreStarting scores. Default 0.
data-clockStarting time on the clock, as mm:ss. Default 00:00.
data-periodA short tag before the clock, such as 2H or Q3.
data-countdownRun the clock down instead of up.
data-stoppedKeep the clock still.
data-goalWord on the tab that drops out. Default “Goal”.
data-autoplayA random team scores every few seconds, for demos.

animacious.scoreBug(el, 'away', 3) sets a score directly; a lower number rolls back down without the goal flash.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Score Bug: a compact scoreboard for the corner of a sports stream: team
    // colours, short names, the score and a running match clock. It slides in
    // piece by piece, and when a team scores their number rolls over, their
    // side flashes and a "Goal" tab drops out underneath.
    // Usage: <div data-score-bug data-home="LDN" data-away="MCR"></div>
    // Size it with font-size.
    // Optional: data-home-color / data-away-color (team colours, default
    // #ef4444 and #3b82f6), data-home-score / data-away-score (default 0),
    // data-clock (starting time "mm:ss", default "00:00"), data-period (a
    // short tag before the clock, e.g. "2H"), data-countdown (the clock runs
    // down instead of up), data-stopped (the clock doesn't run),
    // data-goal (word on the tab, default "Goal"), data-autoplay (a random
    // team scores every few seconds; handy for demos).
    // Script use: animacious.scoreBug(el, 'home' | 'away') adds a point;
    // animacious.scoreBug(el, 'home', 3) sets it.
    var DEFAULTS = { home: 'HOM', away: 'AWY', homeColor: '#ef4444', awayColor: '#3b82f6', goal: 'Goal', every: 5200 };

    var css =
      '.scb{display:inline-block;line-height:1;font-weight:800;color:#fff;font-variant-numeric:tabular-nums}' +
      '.scb-in{position:relative;display:inline-flex;align-items:stretch}' +
      '.scb-main{position:relative;z-index:1;display:flex;align-items:stretch;border-radius:0.3em;overflow:hidden;background:#0f1220;box-shadow:0 0.3em 1.2em rgba(0,0,0,.35)}' +
      '.scb-team{position:relative;display:flex;align-items:center;gap:0.45em;padding:0.5em 0.65em;letter-spacing:0.06em;overflow:hidden}' +
      '.scb-team::before{content:"";width:0.28em;align-self:stretch;margin:-0.15em 0;border-radius:0.1em;background:var(--scb-t)}' +
      '.scb-away{flex-direction:row-reverse}' +
      '.scb-flash{position:absolute;inset:0;background:var(--scb-t);opacity:0}' +
      '.scb-name{position:relative}' +
      '.scb-scores{display:flex;align-items:center;gap:0.35em;padding:0 0.6em;background:#fff;color:#0f1220;font-size:1.15em}' +
      '.scb-num{position:relative;display:inline-block;overflow:hidden;height:1.1em;line-height:1.1em;min-width:0.62em;text-align:center}' +
      '.scb-num span{display:block}' +
      '.scb-dash{opacity:.35;font-weight:700}' +
      '.scb-clock{display:flex;align-items:center;gap:0.5em;padding:0.5em 0.7em;background:#1c2135;font-weight:700}' +
      '.scb-period{font-size:0.72em;letter-spacing:0.08em;opacity:.65}' +
      '.scb-tab{position:absolute;top:100%;left:0.3em;overflow:hidden;pointer-events:none}' +
      '.scb-tab span{display:block;padding:0.4em 0.75em;border-radius:0 0 0.3em 0.3em;background:var(--scb-t);text-transform:uppercase;letter-spacing:0.14em;font-size:0.72em;white-space:nowrap;transform:translateY(-101%)}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function pad(n) { return (n < 10 ? '0' : '') + n; }
    function fmt(t) { return pad(Math.floor(t / 60)) + ':' + pad(t % 60); }

    function team(side, name, color) {
      var t = document.createElement('span');
      t.className = 'scb-team scb-' + side;
      t.style.setProperty('--scb-t', color);
      t.innerHTML = '<span class="scb-flash"></span><span class="scb-name"></span>';
      t.lastChild.textContent = name;
      return t;
    }

    function setup(el) {
      var d = el.dataset;
      var s = {
        names: { home: d.home || DEFAULTS.home, away: d.away || DEFAULTS.away },
        colors: { home: d.homeColor || DEFAULTS.homeColor, away: d.awayColor || DEFAULTS.awayColor },
        score: { home: +d.homeScore || 0, away: +d.awayScore || 0 },
        dir: d.countdown !== undefined && d.countdown !== 'false' ? -1 : 1
      };
      var parts = (d.clock || '00:00').split(':');
      s.time = (+parts[0] || 0) * 60 + (+parts[1] || 0);
      el.classList.add('scb');
      el.setAttribute('role', 'group');
      el.textContent = '';

      var inner = document.createElement('span');
      inner.className = 'scb-in';
      inner.setAttribute('aria-hidden', 'true');
      var main = document.createElement('span');
      main.className = 'scb-main';
      s.teams = { home: team('home', s.names.home, s.colors.home), away: team('away', s.names.away, s.colors.away) };
      var scores = document.createElement('span');
      scores.className = 'scb-scores';
      s.nums = {};
      ['home', 'away'].forEach(function (side, i) {
        if (i) {
          var dash = document.createElement('span');
          dash.className = 'scb-dash';
          dash.textContent = '–';
          scores.appendChild(dash);
        }
        var n = document.createElement('span');
        n.className = 'scb-num';
        n.innerHTML = '<span></span>';
        n.firstChild.textContent = s.score[side];
        scores.appendChild(n);
        s.nums[side] = n;
      });
      var clock = document.createElement('span');
      clock.className = 'scb-clock';
      if (d.period) {
        var p = document.createElement('span');
        p.className = 'scb-period';
        p.textContent = d.period;
        clock.appendChild(p);
      }
      s.clock = document.createElement('span');
      s.clock.textContent = fmt(s.time);
      clock.appendChild(s.clock);
      main.appendChild(s.teams.home);
      main.appendChild(scores);
      main.appendChild(s.teams.away);
      main.appendChild(clock);
      inner.appendChild(main);

      s.tab = document.createElement('span');
      s.tab.className = 'scb-tab';
      s.tab.innerHTML = '<span></span>';
      inner.appendChild(s.tab);
      el.appendChild(inner);
      s.parts = [s.teams.home, scores, s.teams.away, clock];
      el._scb = s;
      label(el);
      if (!reduced()) anime.utils.set(main, { clipPath: 'inset(0 100% 0 0 round 0.3em)' });
    }

    function label(el) {
      var s = el._scb;
      el.setAttribute('aria-label', s.names.home + ' ' + s.score.home + ', ' + s.names.away + ' ' + s.score.away);
    }

    // Roll the number over: the old one goes up and out, the new one comes up from below.
    function setScore(el, side, value) {
      var s = el._scb;
      var old = s.score[side];
      s.score[side] = value;
      label(el);
      var box = s.nums[side];
      if (reduced()) { box.firstChild.textContent = value; return; }
      var prev = box.firstChild;
      var next = document.createElement('span');
      next.textContent = value;
      next.style.cssText = 'position:absolute;left:0;right:0;top:0';
      box.appendChild(next);
      var up = value >= old;
      anime.animate(prev, { y: ['0%', up ? '-100%' : '100%'], duration: 450, delay: 250, ease: 'inOut(3)' });
      anime.animate(next, {
        y: [up ? '100%' : '-100%', '0%'], duration: 450, delay: 250, ease: 'inOut(3)',
        onComplete: function () { prev.remove(); next.style.cssText = ''; }
      });
      if (!up) return;
      // A goal: their side flashes, and the tab drops out in their colour.
      var t = s.teams[side];
      anime.animate(t.firstChild, { opacity: [0, 0.9, 0, 0.9, 0], duration: 1100, ease: 'linear' });
      var tab = s.tab.firstChild;
      tab.textContent = (el.dataset.goal || DEFAULTS.goal) + ' · ' + s.names[side];
      s.tab.style.setProperty('--scb-t', s.colors[side]);
      s.tab.style.left = side === 'home' ? '0.3em' : 'auto';
      s.tab.style.right = side === 'home' ? 'auto' : '0.3em';
      if (s.tabTl) s.tabTl.pause();
      s.tabTl = anime.createTimeline()
        .add(tab, { y: ['-101%', '0%'], duration: 450, ease: 'out(4)' })
        .add(tab, { y: '-101%', duration: 350, ease: 'in(3)' }, '+=1900');
    }

    function play(el) {
      var s = el._scb, d = el.dataset;
      var main = el.querySelector('.scb-main');
      anime.animate(main, { clipPath: ['inset(0 100% 0 0 round 0.3em)', 'inset(0 0% 0 0 round 0.3em)'], duration: 700, ease: 'inOut(3)' });
      anime.animate(s.parts, { opacity: [0, 1], x: ['-0.6em', '0em'], duration: 500, delay: anime.stagger(90, { start: 250 }), ease: 'out(3)' });

      var running = d.stopped === undefined || d.stopped === 'false';
      var auto = d.autoplay !== undefined && d.autoplay !== 'false';
      var visible = true;
      new IntersectionObserver(function (entries) { visible = entries[0].isIntersecting; }).observe(el);
      if (running) {
        setInterval(function () {
          if (!visible) return;
          s.time = Math.max(0, s.time + s.dir);
          s.clock.textContent = fmt(s.time);
        }, 1000);
      }
      if (auto) {
        setInterval(function () {
          if (!visible) return;
          if (s.score.home + s.score.away >= 6) {
            setScore(el, 'home', 0);
            setScore(el, 'away', 0);
            return;
          }
          var side = Math.random() < 0.5 ? 'home' : 'away';
          setScore(el, side, s.score[side] + 1);
        }, DEFAULTS.every);
      }
    }

    window.animacious = window.animacious || {};
    window.animacious.scoreBug = function (el, side, value) {
      if (!el || !el._scb || (side !== 'home' && side !== 'away')) return;
      setScore(el, side, value === undefined ? el._scb.score[side] + 1 : +value);
    };

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          if (!e.isIntersecting) return;
          io.unobserve(e.target);
          play(e.target);
        });
      }, { threshold: 0.2 });
      document.querySelectorAll('[data-score-bug]').forEach(function (el) {
        if (el._scb) return;
        setup(el);
        if (!reduced()) io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-score-bug
     data-home="LDN" data-home-color="#ef4444"
     data-away="MCR" data-away-color="#38bdf8"
     data-clock="45:00" data-period="2H"></div>

<script>
  // when the home side scores:
  animacious.scoreBug(document.querySelector('[data-score-bug]'), 'home');
</script>