← Broadcast

Follower Alert

A stream alert for a new follower. The card springs in, the heart badge bursts into confetti with a shock ring, the name bounces in letter by letter, a sheen sweeps across, the heart beats, then it pops away. Loop it with a list of names and each pass shows the next.

New follower

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-follower-alert to an element whose text is the small label, with the follower in data-name. Size it with font-size.
  3. Call animacious.followerAlert(el, 'their_name') whenever someone follows.

Options

Add any of these attributes to the same element.

data-nameThe follower’s name. Give several, comma-separated, and each loop shows the next.
data-colorBadge and accent colour. Default #a855f7.
data-holdHow long it stays on screen. Default 2600 ms.
data-loopCome back with the next name, over and over.
data-gapTime off screen between loops. Default 900 ms.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Follower Alert: a stream alert for a new follower. The card pops in with
    // a springy bounce, the heart badge bursts into confetti and a shock ring,
    // the name bounces in letter by letter, a sheen sweeps across, then it
    // pops away again.
    // Usage: <div data-follower-alert data-name="pixel_panda">New follower</div>
    // The element's text is the small label above the name. Size it with
    // font-size.
    // Optional: data-name (the follower's name; give several, comma-separated,
    // and each loop shows the next), data-color (badge and accent colour,
    // default #a855f7), data-hold (ms on screen, default 2600), data-loop
    // (come back with the next name, over and over), data-gap (ms off screen
    // between loops, default 900).
    // Script use: animacious.followerAlert(el, 'new_name') shows an alert now.
    var DEFAULTS = { color: '#a855f7', hold: 2600, gap: 900, label: 'New follower', name: 'new_follower' };
    var BURST = ['#fbbf24', '#f472b6', '#38bdf8', '#4ade80', '#ffffff'];

    var css =
      '.fla{display:inline-block;position:relative;line-height:1.15;text-align:left;--fla-c:' + DEFAULTS.color + '}' +
      '.fla-card{position:relative;display:flex;align-items:center;gap:0.85em;padding:0.7em 1.4em 0.7em 0.7em;border-radius:1.1em;color:#fff;' +
        'background:linear-gradient(135deg,rgba(30,20,52,.96),rgba(46,22,82,.96));border:1px solid rgba(255,255,255,.14);' +
        'box-shadow:0 0.8em 2em rgba(0,0,0,.4),0 0 0 0.12em color-mix(in srgb,var(--fla-c) 45%,transparent)}' +
      '.fla-clip{position:absolute;inset:0;border-radius:inherit;overflow:hidden;pointer-events:none}' +
      '.fla-sheen{position:absolute;top:-20%;bottom:-20%;left:0;width:35%;background:linear-gradient(100deg,transparent,rgba(255,255,255,.28),transparent);transform:translateX(-150%) skewX(-18deg)}' +
      '.fla-badge{position:relative;flex:none;width:2.7em;height:2.7em}' +
      '.fla-icon{position:absolute;inset:0;display:grid;place-items:center;border-radius:50%;background:radial-gradient(circle at 35% 30%,color-mix(in srgb,var(--fla-c) 55%,#fff),var(--fla-c) 70%);box-shadow:0 0 1em color-mix(in srgb,var(--fla-c) 70%,transparent)}' +
      '.fla-icon svg{width:1.35em;height:1.35em;fill:#fff}' +
      '.fla-ring{position:absolute;inset:0;border-radius:50%;border:0.14em solid var(--fla-c);opacity:0}' +
      '.fla-p{position:absolute;left:50%;top:50%;width:0.42em;height:0.42em;margin:-0.21em;border-radius:50%;opacity:0}' +
      '.fla-p.fla-sq{border-radius:0.08em}' +
      '.fla-text{display:flex;flex-direction:column;gap:0.12em;min-width:0}' +
      '.fla-label{font-size:0.7em;font-weight:700;letter-spacing:0.16em;text-transform:uppercase;color:color-mix(in srgb,var(--fla-c) 40%,#fff)}' +
      '.fla-name{font-size:1.55em;font-weight:800;letter-spacing:-0.01em;white-space:nowrap}' +
      '.fla-ch{display:inline-block;white-space:pre}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    var HEART = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 21s-7.5-4.6-9.6-9.2C.9 8.4 3 4.5 6.8 4.5c2.1 0 3.6 1.1 5.2 3 1.6-1.9 3.1-3 5.2-3 3.8 0 5.9 3.9 4.4 7.3C19.5 16.4 12 21 12 21z"/></svg>';

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function opt(el, k) { var v = el.dataset[k]; return v !== undefined && v !== '' ? +v : DEFAULTS[k]; }

    function setup(el) {
      var d = el.dataset;
      var label = el.textContent.trim() || DEFAULTS.label;
      var names = (d.name || DEFAULTS.name).split(',').map(function (s) { return s.trim(); }).filter(Boolean);
      el.classList.add('fla');
      el.style.setProperty('--fla-c', d.color || DEFAULTS.color);
      el.setAttribute('role', 'status');
      el.innerHTML =
        '<div class="fla-card" aria-hidden="true">' +
          '<span class="fla-clip"><span class="fla-sheen"></span></span>' +
          '<span class="fla-badge"><span class="fla-ring"></span><span class="fla-icon">' + HEART + '</span></span>' +
          '<span class="fla-text"><span class="fla-label"></span><span class="fla-name"></span></span>' +
        '</div>';
      var s = {
        names: names, index: 0, label: label,
        card: el.querySelector('.fla-card'),
        sheen: el.querySelector('.fla-sheen'),
        badge: el.querySelector('.fla-badge'),
        icon: el.querySelector('.fla-icon'),
        ring: el.querySelector('.fla-ring'),
        labelEl: el.querySelector('.fla-label'),
        nameEl: el.querySelector('.fla-name'),
        parts: []
      };
      s.labelEl.textContent = label;
      // Confetti pieces live inside the badge so they burst from its centre.
      for (var i = 0; i < 16; i++) {
        var p = document.createElement('span');
        p.className = 'fla-p' + (i % 3 === 0 ? ' fla-sq' : '');
        p.style.background = i % 4 === 0 ? 'var(--fla-c)' : BURST[i % BURST.length];
        s.badge.appendChild(p);
        s.parts.push(p);
      }
      el._fla = s;
      setName(el, names[0]);
      if (!reduced()) anime.utils.set(s.card, { opacity: 0, scale: 0.4 });
    }

    function setName(el, name) {
      var s = el._fla;
      s.nameEl.textContent = '';
      s.chars = name.split('').map(function (c) {
        var sp = document.createElement('span');
        sp.className = 'fla-ch';
        sp.textContent = c;
        s.nameEl.appendChild(sp);
        return sp;
      });
      el.setAttribute('aria-label', s.label + ': ' + name);
    }

    function show(el, name) {
      var s = el._fla;
      if (s.tl) s.tl.pause();
      clearTimeout(s.wait);
      setName(el, name);
      var loop = el.dataset.loop !== undefined && el.dataset.loop !== 'false';
      var hold = opt(el, 'hold');

      anime.utils.set(s.card, { opacity: 0, scale: 0.4, y: '0em', rotate: -4 });
      anime.utils.set(s.icon, { scale: 0, rotate: -120 });
      anime.utils.set(s.ring, { scale: 1, opacity: 0 });
      anime.utils.set(s.labelEl, { opacity: 0, x: '-0.6em' });
      anime.utils.set(s.chars, { opacity: 0, y: '0.7em', scale: 0.6 });
      anime.utils.set(s.sheen, { x: '-150%' });
      anime.utils.set(s.parts, { x: 0, y: 0, scale: 1, opacity: 0, rotate: 0 });

      var tl = anime.createTimeline();
      tl.add(s.card, { opacity: [0, 1], scale: [0.4, 1], rotate: [-4, 0], duration: 800, ease: 'outElastic(1, .6)' })
        .add(s.icon, { scale: [0, 1], rotate: [-120, 0], duration: 700, ease: 'outBack(2.2)' }, 120)
        .add(s.ring, { scale: [1, 2.8], opacity: [0.9, 0], duration: 700, ease: 'out(3)' }, 300);
      s.parts.forEach(function (p, i) {
        var a = (i / s.parts.length) * Math.PI * 2 + anime.utils.random(-0.25, 0.25, 2);
        var r = anime.utils.random(2.2, 4.4, 2);
        tl.add(p, {
          x: Math.cos(a) * r + 'em',
          y: Math.sin(a) * r + 'em',
          rotate: anime.utils.random(-260, 260),
          scale: [1.2, 0],
          opacity: [{ to: 1, duration: 60 }, { to: 1, duration: 420 }, { to: 0, duration: 260 }],
          duration: 740,
          ease: 'out(3)'
        }, 300);
      });
      tl.add(s.labelEl, { opacity: [0, 1], x: ['-0.6em', '0em'], duration: 420, ease: 'out(3)' }, 320)
        .add(s.chars, { opacity: [0, 1], y: ['0.7em', '0em'], scale: [0.6, 1], duration: 620, ease: 'outBack(2.6)', delay: anime.stagger(32) }, 420)
        .add(s.sheen, { x: ['-150%', '420%'], duration: 900, ease: 'inOut(2)' }, 700)
        // a heartbeat while it holds
        .add(s.icon, { scale: [1, 1.18, 1, 1.12, 1], duration: 900, ease: 'inOut(2)' }, 1300)
        .add(s.card, { opacity: 0, scale: 0.85, y: '-0.4em', duration: 360, ease: 'in(3)' }, 1100 + hold);
      tl.then(function () {
        if (!loop) return;
        s.wait = setTimeout(function () {
          s.index = (s.index + 1) % s.names.length;
          show(el, s.names[s.index]);
        }, opt(el, 'gap'));
      });
      s.tl = tl;
    }

    window.animacious = window.animacious || {};
    window.animacious.followerAlert = function (el, name) {
      if (!el || !el._fla) return;
      if (reduced()) { setName(el, name || el._fla.names[0]); return; }
      show(el, name || el._fla.names[el._fla.index]);
    };

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          if (!e.isIntersecting) return;
          io.unobserve(e.target);
          var s = e.target._fla;
          setTimeout(function () { show(e.target, s.names[0]); }, 250);
        });
      }, { threshold: 0.2 });
      document.querySelectorAll('[data-follower-alert]').forEach(function (el) {
        if (el._fla) return;
        setup(el);
        if (!reduced()) io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-follower-alert data-name="pixel_panda">New follower</div>

<script>
  // when your streaming tool reports a follow:
  animacious.followerAlert(document.querySelector('[data-follower-alert]'), 'nova_streams');
</script>