← Buttons

Badge Bump

A notification count that pops when it changes: the badge swells and springs back with a ring pulsing out, the old number rolls away and the new one rolls in, and the bell gives a little ring. Change data-count and it does the rest.

How to use it

  1. Paste the snippet just before </head>.
  2. Add <span data-badge-bump data-count="3"></span> inside your icon and position it with your own CSS.
  3. Set badge.dataset.count whenever the number changes.

Options

Add any of these attributes to the same element.

data-countThe number. At 0 the badge shrinks away.
data-maxAbove this it shows, say, “99+”. Default 99.
data-wiggleWiggle the element it sits in, like a ringing bell, when the count goes up.
data-colorBadge colour. Default #ef4444.
data-autoplayThe count goes up by itself every couple of seconds while in view, and clears now and then. Handy for demos.

Position the badge with top and right rather than a transform, which the bump uses.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Badge Bump: a notification count that pops when it changes. The badge
    // swells and springs back with a ring pulsing out of it, while the old
    // number rolls away and the new one rolls in (up when it goes up, down when
    // it goes down). At zero the badge shrinks away; above data-max it shows
    // "99+". With data-wiggle, the icon it sits on gives a little ring too.
    // Usage: <span data-badge-bump data-count="3"></span>
    //   Change the count with badge.dataset.count = 4 (or setAttribute) and it
    //   bumps by itself. Position it over your icon with your own CSS.
    // Optional: data-color (badge colour, default #ef4444), data-max (default 99),
    // data-wiggle (wiggle the badge's parent, like a ringing bell),
    // data-autoplay (the count goes up by itself every couple of seconds while in
    // view and clears now and then; handy for demos).
    var DEFAULTS = { color: '#ef4444', max: 99, every: 1700 };

    var css =
      '.bdb{position:relative;display:inline-grid;place-items:center;box-sizing:border-box;min-width:1.6em;height:1.6em;padding:0 0.45em;' +
      'border-radius:0.8em;background:var(--bdb-color);color:#fff;font-weight:700;font-size:0.75em;line-height:1;font-variant-numeric:tabular-nums}' +
      '.bdb-num{display:grid;overflow:hidden;height:1.2em;line-height:1.2em}' +
      '.bdb-num > span{grid-area:1/1;text-align:center}' +
      '.bdb-ring{position:absolute;inset:0;border-radius:inherit;box-shadow:0 0 0 0.15em var(--bdb-color);opacity:0;pointer-events:none}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    var reduced = function () { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; };

    function label(el, n) {
      var max = +el.dataset.max || DEFAULTS.max;
      return n > max ? max + '+' : String(n);
    }

    function setup(el) {
      el.classList.add('bdb');
      el.style.setProperty('--bdb-color', el.dataset.color || DEFAULTS.color);
      var num = document.createElement('span');
      num.className = 'bdb-num';
      var cur = document.createElement('span');
      var n = Math.max(0, parseInt(el.dataset.count, 10) || 0);
      cur.textContent = label(el, n);
      num.appendChild(cur);
      var ring = document.createElement('span');
      ring.className = 'bdb-ring';
      ring.setAttribute('aria-hidden', 'true');
      el.textContent = '';
      el.appendChild(num);
      el.appendChild(ring);
      el._bdb = { n: n, num: num, cur: cur, ring: ring };
      if (!n) {
        anime.utils.set(el, { scale: 0 });
        el.setAttribute('aria-hidden', 'true');
      }
      // Watch the attribute, so setting the count is all it takes.
      new MutationObserver(function () { update(el); }).observe(el, { attributes: true, attributeFilter: ['data-count'] });
    }

    function update(el) {
      var s = el._bdb;
      var n = Math.max(0, parseInt(el.dataset.count, 10) || 0);
      if (n === s.n) return;
      var up = n > s.n, was = s.n;
      s.n = n;
      var next = document.createElement('span');
      next.textContent = label(el, n);
      if (!n) el.setAttribute('aria-hidden', 'true');
      else el.removeAttribute('aria-hidden');
      var old = s.cur;
      s.cur = next;
      if (reduced()) {
        old.remove();
        s.num.appendChild(next);
        anime.utils.set(el, { scale: n ? 1 : 0 });
        return;
      }
      if (!n) {
        // Cleared: shrink away (and keep the old number while it goes).
        s.cur = old;
        anime.animate(el, { scale: 0, duration: 300, ease: 'in(2)', onComplete: function () { if (!s.n) old.textContent = '0'; } });
        return;
      }
      s.num.appendChild(next);
      if (!was) {
        // Back from zero: no number to roll, just pop in.
        old.remove();
        anime.animate(el, { scale: [0, 1], duration: 700, ease: 'outElastic(1, .5)' });
      } else {
        anime.animate(old, { y: ['0%', up ? '-110%' : '110%'], duration: 300, ease: 'inOut(2)', onComplete: function () { old.remove(); } });
        anime.animate(next, { y: [up ? '110%' : '-110%', '0%'], duration: 300, ease: 'inOut(2)' });
        anime.animate(el, { scale: [{ to: 1.35, duration: 140, ease: 'out(3)' }, { to: 1, duration: 650, ease: 'outElastic(1, .4)' }] });
      }
      anime.animate(s.ring, { scale: [1, 1.9], opacity: [0.7, 0], duration: 650, delay: 60, ease: 'out(3)' });
      if (el.dataset.wiggle !== undefined && el.dataset.wiggle !== 'false' && up && el.parentElement) {
        var p = el.parentElement;
        anime.utils.set(p, { transformOrigin: '50% 15%' });
        anime.animate(p, { rotate: [0, 16, -13, 9, -5, 2, 0], duration: 700, ease: 'out(1.5)' });
      }
    }

    function init() {
      document.querySelectorAll('[data-badge-bump]').forEach(function (el) {
        if (el._bdb) return;
        setup(el);
        if (el.dataset.autoplay !== undefined && el.dataset.autoplay !== 'false' && !reduced()) {
          var steps = [1, 1, 2, 1, 3, 1, 'clear'];
          var i = 0;
          var timer = null;
          var tick = function () {
            var step = steps[i++ % steps.length];
            el.dataset.count = step === 'clear' ? 0 : el._bdb.n + step;
            timer = setTimeout(tick, step === 'clear' ? DEFAULTS.every * 1.4 : DEFAULTS.every);
          };
          new IntersectionObserver(function (entries) {
            clearTimeout(timer);
            if (entries[0].isIntersecting) timer = setTimeout(tick, 600);
          }).observe(el.parentElement || el);
        }
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<button class="bell" aria-label="Notifications">
  <svg>…</svg>
  <span data-badge-bump data-count="3" data-wiggle></span>
</button>
<script>
  // when a new notification arrives:
  const badge = document.querySelector("[data-badge-bump]");
  badge.dataset.count = +badge.dataset.count + 1;
</script>