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
- Paste the snippet just before
</head>. - Add
<span data-badge-bump data-count="3"></span>inside your icon and position it with your own CSS. - Set
badge.dataset.countwhenever the number changes.
Options
Add any of these attributes to the same element.
data-count | The number. At 0 the badge shrinks away. |
data-max | Above this it shows, say, “99+”. Default 99. |
data-wiggle | Wiggle the element it sits in, like a ringing bell, when the count goes up. |
data-color | Badge colour. Default #ef4444. |
data-autoplay | The 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.
<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><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>