Live Indicator
A red “LIVE” badge whose dot beats like a recording light, sending out a soft ring each time. Give it a viewer count and the number drifts up and down like a real audience.
LiveLive
How to use it
- Paste the snippet just before
</head>. - Add
data-live-indicatorto a<span>with the word to show. Size it withfont-size.
Options
Add any of these attributes to the same element.
data-color | Badge colour. Default #ef4444. |
data-variant | solid (default) or outline: a dark pill with a red dot. |
data-viewers | A starting viewer count to show beside the badge, which then drifts. |
data-speed | Beats per minute of the dot. Default 60. |
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Live Indicator: a red "LIVE" badge with a dot that beats and sends out a
// soft ring, like a recording light. It pops in when first seen. Add a
// viewer count and it drifts up and down like a real audience.
// Usage: <span data-live-indicator>Live</span>
// Size it with font-size.
// Optional: data-color (badge colour, default #ef4444), data-variant
// ("solid", default, or "outline": a dark pill with a red dot),
// data-viewers (a starting viewer count shown beside it, e.g. 1284),
// data-speed (beats per minute of the dot, default 60).
var DEFAULTS = { color: '#ef4444', speed: 60 };
var css =
'.lvi{display:inline-flex;align-items:center;gap:0.5em;line-height:1;font-weight:800;vertical-align:middle;--lvi-c:' + DEFAULTS.color + '}' +
'.lvi-badge{display:inline-flex;align-items:center;gap:0.45em;padding:0.38em 0.7em 0.36em 0.6em;border-radius:0.35em;background:var(--lvi-c);color:#fff;text-transform:uppercase;letter-spacing:0.1em;font-size:1em;box-shadow:0 0.15em 0.6em rgba(0,0,0,.2)}' +
'.lvi-outline .lvi-badge{background:rgba(10,10,18,.82);border:1px solid rgba(255,255,255,.18)}' +
'.lvi-dot{position:relative;flex:none;width:0.55em;height:0.55em}' +
'.lvi-core,.lvi-ring{position:absolute;inset:0;border-radius:50%;background:#fff}' +
'.lvi-outline .lvi-core,.lvi-outline .lvi-ring{background:var(--lvi-c)}' +
'.lvi-ring{opacity:0}' +
'.lvi-count{display:inline-flex;align-items:center;gap:0.35em;padding:0.38em 0.6em 0.36em;border-radius:0.35em;background:rgba(10,10,18,.72);color:#fff;font-weight:700;font-size:0.9em;font-variant-numeric:tabular-nums}' +
'.lvi-count svg{width:1em;height:1em;flex:none}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function fmt(n) {
if (n >= 10000) return (n / 1000).toFixed(n >= 100000 ? 0 : 1) + 'K';
return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
var EYE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg>';
function setup(el) {
var d = el.dataset;
var text = el.textContent.trim() || 'Live';
el.classList.add('lvi');
if (d.variant === 'outline') el.classList.add('lvi-outline');
el.style.setProperty('--lvi-c', d.color || DEFAULTS.color);
el.textContent = '';
var badge = document.createElement('span');
badge.className = 'lvi-badge';
badge.innerHTML = '<span class="lvi-dot" aria-hidden="true"><span class="lvi-ring"></span><span class="lvi-core"></span></span>';
var label = document.createElement('span');
label.textContent = text;
badge.appendChild(label);
el.appendChild(badge);
var s = { badge: badge, core: badge.querySelector('.lvi-core'), ring: badge.querySelector('.lvi-ring') };
if (d.viewers !== undefined) {
var count = document.createElement('span');
count.className = 'lvi-count';
count.innerHTML = EYE + '<span></span>';
s.n = Math.max(0, Math.round(+d.viewers) || 0);
s.num = count.lastChild;
s.num.textContent = fmt(s.n);
count.setAttribute('aria-label', s.n + ' watching');
el.appendChild(count);
s.count = count;
}
el._lvi = s;
if (!reduced()) anime.utils.set(el.children, { scale: 0, opacity: 0 });
}
function play(el) {
var s = el._lvi, d = el.dataset;
var beat = 60000 / (+d.speed || DEFAULTS.speed);
anime.animate(el.children, {
scale: [0, 1], opacity: [0, 1], duration: 700, delay: anime.stagger(120), ease: 'outElastic(1, .6)'
});
// A heartbeat: the dot swells and dims, a ring spreads out from it.
s.beat = anime.createTimeline({ loop: true, delay: 400 })
.add(s.core, { scale: [1, 1.25, 1], opacity: [1, 1, 0.55, 1], duration: beat, ease: 'inOut(2)' })
.add(s.ring, { scale: [1, 2.8], opacity: [0.7, 0], duration: beat * 0.95, ease: 'out(3)' }, 0);
startCount(el);
}
// The audience ebbs and flows: small random steps, slightly upwards.
function startCount(el) {
var s = el._lvi;
if (!s.count) return;
clearTimeout(s.timer);
var step = function () {
var delta = Math.round((Math.random() - 0.38) * Math.max(3, s.n * 0.004));
s.n = Math.max(0, s.n + delta);
s.num.textContent = fmt(s.n);
s.count.setAttribute('aria-label', s.n + ' watching');
if (delta) anime.animate(s.num, { y: [delta > 0 ? '30%' : '-30%', '0%'], opacity: [0.4, 1], duration: 300, ease: 'out(3)' });
s.timer = setTimeout(step, 900 + Math.random() * 1400);
};
s.timer = setTimeout(step, 1500);
}
function stop(el) {
var s = el._lvi;
if (s.beat) s.beat.pause();
clearTimeout(s.timer);
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
var el = e.target, s = el._lvi;
if (e.isIntersecting) {
if (!s.played) { s.played = true; play(el); }
else { s.beat.play(); startCount(el); }
} else if (s.played) stop(el);
});
}, { threshold: 0.2 });
document.querySelectorAll('[data-live-indicator]').forEach(function (el) {
if (el._lvi) return;
setup(el);
if (!reduced()) io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><span data-live-indicator>Live</span>
<!-- with a viewer count: -->
<span data-live-indicator data-viewers="1284">Live</span>