← Broadcast

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

  1. Paste the snippet just before </head>.
  2. Add data-live-indicator to a <span> with the word to show. Size it with font-size.

Options

Add any of these attributes to the same element.

data-colorBadge colour. Default #ef4444.
data-variantsolid (default) or outline: a dark pill with a red dot.
data-viewersA starting viewer count to show beside the badge, which then drifts.
data-speedBeats per minute of the dot. Default 60.
1 · Snippet
<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>
2 · Your HTML
<span data-live-indicator>Live</span>

<!-- with a viewer count: -->
<span data-live-indicator data-viewers="1284">Live</span>