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
- Paste the snippet just before
</head>. - Add
data-score-bugto an empty element, withdata-homeanddata-awayset to short team names. Size it withfont-size. - 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-color | Team colours. Default #ef4444 and #3b82f6. |
data-home-score, data-away-score | Starting scores. Default 0. |
data-clock | Starting time on the clock, as mm:ss. Default 00:00. |
data-period | A short tag before the clock, such as 2H or Q3. |
data-countdown | Run the clock down instead of up. |
data-stopped | Keep the clock still. |
data-goal | Word on the tab that drops out. Default “Goal”. |
data-autoplay | A 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.
<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><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>