<div class="demo">
  <a id="notificationMenuButton" 
     class="notification__link">
    <span class="notification">
      <span class="notification__icon"></span>
    </span>
  </a>

  <a id="cartMenuButton" 
     class="cart__link">
    <span class="cart">
      <span class="cart__icon"></span>
    </span>
  </a>
</div>

<div class="controls">
  <button class="increase" type="button">Increase</button>
  <button class="decrease" type="button">Decrease</button>
  <button class="hide" type="button">Set 0</button>
  <button class="set" type="button">Set 100</button>
  <button class="get" type="button">Get</button>
</div>
:root {
  --badge-bg-color: #fff;
  --badge-text-color: #F74D4D;
  --badge-box-size: 20;
  --animation-speed: .4s;
  --badge-font-size: 13px;
}

@layer support {
  html, body{
    height: 100%;
  }

  body {
    --body-bg: #4984fc;
    background: linear-gradient(90deg, #4984fc 20%, #039ae5 98%);
    padding: 20px;
    text-align: center;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;

    .controls {
      display: flex;
      gap: 4px;
      flex-wrap: wrap;
      align-items: center;
      justify-content: center;
    }

    button {
      display: block;
      background: #fff;
      border: 1px solid transparent;
      padding: 10px 15px;
      border-radius: 4px;
      color: #039ae5;
      text-transform: uppercase;
      font-size: 10px;
      letter-spacing: 1.6px;
      cursor: pointer;
      outline: none !important;
      will-change: color, background, border;
      transition: .2s border ease-in-out, .2s background ease-in-out, .2s color ease-in-out;

      &:hover {
        border: 1px solid #fff;
        color: #fff;
        background: transparent;
      }
    }
  }

  .demo {
    display: flex;
    flex-direction: row;
    gap: 50px;
    margin-bottom: 50px;
  }
}

.cart,
.notification {
  --direction: 1;
  width: 38px;
  height: 38px;
  position: relative;
  display: block;
}

.cart {
  --badge-bg-color: #F74D4D;
  --badge-text-color: #fff;
  --badge-box-size: 16;
  --animation-speed: .4s;
  --badge-font-size: 10px;
}

.notification {
  --badge-bg-color: #fff;
  --badge-text-color: #F74D4D;
  --badge-box-size: 20;
  --animation-speed: .4s;
  --badge-font-size: 13px;
}

.cart__link,
.notification__link {
  position: relative;
  width: 38px;
  height: 38px;
  cursor: pointer;
  display: block;
  margin: 0;
  user-select: none;

  &:after {
    content: '';
    position: absolute;
    width: 50px;
    height: 50px;
    opacity: 0;
    left: 50%;
    top: 50%;
    transform: translate(-50%, -50%);
  }

  &:hover,
  &:focus {
    .cart__icon:after,
    .notification__icon:after {
      animation: scale-over 2s ease-out infinite;
    }
  }
}

.cart__icon,
.notification__icon {
  font-size: 28px;
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;

  &:before,
  &:after {
    content: "";
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    will-change: transform, opacity;
  }

  &:after{
    opacity: 0;
  }
}

.cart__icon:before,
.cart__icon:after {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 128 128'%3E%3Cg fill='none' stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-miterlimit='10' stroke-width='8'%3E%3Ccircle cx='90' cy='102' r='7'%3E%3C/circle%3E%3Ccircle cx='50' cy='102' r='8'%3E%3C/circle%3E%3Cpath d='M30 34h83.1c2.8 0 4.7 2.8 3.7 5.4l-15.9 41.4C99.7 84 96.7 86 93.4 86H46.6c-3.8 0-7.1-2.7-7.9-6.5L30 34zm-.1 0-1.5-7.6c-.7-3.7-4-6.4-7.8-6.4h-7.2M90.9 46h3M33 46h48.9'%3E%3C/path%3E%3C/g%3E%3C/svg%3E");
}

.notification__icon:before,
.notification__icon:after {
    background-image:   url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyMSAyMSI+CiAgPGRlZnM+CiAgICA8c3R5bGU+CiAgICAgIC5jbHMtMSB7CiAgICAgICAgZmlsbDogbm9uZTsKICAgICAgfQoKICAgICAgLmNscy0yIHsKICAgICAgICBmaWxsOiAjZmZmOwogICAgICB9CiAgICA8L3N0eWxlPgogIDwvZGVmcz4KICA8ZyBpZD0iR3JvdXBfMSIgZGF0YS1uYW1lPSJHcm91cCAxIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtNDgwIC0xNjUpIj4KICAgIDxyZWN0IGlkPSJSZWN0YW5nbGVfMSIgZGF0YS1uYW1lPSJSZWN0YW5nbGUgMSIgY2xhc3M9ImNscy0xIiB3aWR0aD0iMjEiIGhlaWdodD0iMjEiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDQ4MCAxNjUpIi8+CiAgICA8cGF0aCBpZD0iYmVsbC1vX2NvcHkiIGRhdGEtbmFtZT0iYmVsbC1vIGNvcHkiIGNsYXNzPSJjbHMtMiIgZD0iTTE3LjMsMTUuN2wtMi4xLTMuMXYtNGE2LjQyMSw2LjQyMSwwLDAsMC00LjYtNi4xVjEuN0ExLjY4NSwxLjY4NSwwLDAsMCw4LjksMCwxLjg0NCwxLjg0NCwwLDAsMCw3LjEsMS43di44QTYuNDIxLDYuNDIxLDAsMCwwLDIuNSw4LjZ2My45TC40LDE1LjZhMS42NjksMS42NjksMCwwLDAtLjEsMS44LDEuNjUsMS42NSwwLDAsMCwxLjUuOUg2QTIuOTg4LDIuOTg4LDAsMCwwLDguOCwyMGgwYTMuMDc3LDMuMDc3LDAsMCwwLDIuOC0xLjhoNC4yYTEuODU5LDEuODU5LDAsMCwwLDEuNS0uOUExLjM0OCwxLjM0OCwwLDAsMCwxNy4zLDE1LjdabS0xLDEuMmEuNTUuNTUsMCwwLDEtLjUuM0gxMC43bC0uMS40YTEuOTIyLDEuOTIyLDAsMCwxLTEuOCwxLjNBMS43NSwxLjc1LDAsMCwxLDcsMTcuNmwtLjEtLjRIMS44YS41NS41NSwwLDAsMS0uNS0uMy42MzguNjM4LDAsMCwxLDAtLjZsMi40LTMuNVY4LjZhNS4xMjcsNS4xMjcsMCwwLDEsNC4xLTVsLjUtLjFWMS43YS41LjUsMCwxLDEsMSwwVjMuNWwuNS4xYTUuMTI3LDUuMTI3LDAsMCwxLDQuMSw1djQuM2wyLjMsMy41QzE2LjQsMTYuNSwxNi40LDE2LjcsMTYuMywxNi45WiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoNDgxLjYxNCAxNjUuNSkiLz4KICA8L2c+Cjwvc3ZnPgo=");
}

.badge {
  display: block;
  position: absolute;
  top: 5px;
  right: 1px;
  background: var(--badge-bg-color);
  border-radius: 100%;
  overflow: hidden;
  width: calc(var(--badge-box-size) * 1px);
  height: calc(var(--badge-box-size) * 1px);
  border: 2px solid var(--badge-bg-color);
  will-change: transform, background;
  transition: var(--animation-speed) transform ease-in-out, var(--animation-speed) background ease-in-out, var(--animation-speed) box-shadow ease-in-out;
  transform: scale(0);
  box-sizing: border-box;
  box-shadow: 0 0 0 3px var(--body-bg, #fff);

  &.badge--active {
    transform: scale(1);
  }

  &.badge--animate {
    transform: scale(1.1);
    box-shadow: 0 0 0 2px var(--body-bg, #fff);
    
    .badge__inner {
      transition: var(--animation-speed) transform ease-in;
      transform: translate(0, calc(var(--direction) * 25% - 25%));
    }
  }

  &.badge--limit {
    background: var(--badge-text-color);
  }
}

.badge__inner {
  width: 100%;
  height: 200%;
  position: absolute;
  top: 0;
  left: 0;
  will-change: transform;
  transform: translate(0, calc(var(--direction) * -25% - 25%));
}

.badge__counter {
  display: flex;
  align-items: center;
  justify-content: center;
  position: absolute;
  color: var(--badge-text-color);
  font-weight: 600;
  font-family: 'Open Sans', sans-serif;
  font-size: max(11px, var(--badge-font-size));
  line-height: var(--badge-box-size);
  width: 100%;
  height: 50%;
  top: 0;
  left: 0;
  text-align: center;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  letter-spacing: -0.6px;
  opacity: 1;
  transform: translate(0, 0);
  will-change: transform;
  transition: none;
  
  &.badge__counter--old {
    transform: translate(0, calc(var(--direction) * 50% + 50%));
  }
  
  &.badge__counter--new {
    transform: translate(0, calc(var(--direction) * -50% + 50%));
  }
}

@keyframes scale-over {
  0%, 100% {
    transform: scale(1);
    opacity: .7;
  }
  20% {
    transform: scale(1.5);
    opacity: 0;
  }
  20.0001% {
    transform: scale(1);
    opacity: .7;
  }
}
class Badge {  
  #class = {
    badge: {
      node: 'badge',
      active: 'badge--active',
      limit: 'badge--limit',
      animate: 'badge--animate',
      inner: 'badge__inner'
    },
    counter: {
      node: 'badge__counter',
      old: 'badge__counter--old',
      new: 'badge__counter--new'
    }
  }

  constructor(element, options) {
    this.options = options || {
      start: 0,
      animationSpeed: 200
    }

    if (!element) return;
    this.element = element;
    this.badgeNode = null;
    this.valueNode = null;
    this.newValueNode = null;
    this.value = parseInt(this.options.start) || 0;
    this.render();
    
    this.element.style.setProperty('--direction', 1);
    this.element.style.setProperty('--animation-speed', (this.options.animationSpeed || 200) + 'ms');

    if (this.value > 0) {
      let temp = this.value;
      this.show();
    }
  }

  render() {
    // create and append
    this.badgeNode = document.createElement('DIV');
    this.badgeNode.className = this.#class.badge.node;

    this.badgeInnerNode = document.createElement('DIV');
    this.badgeInnerNode.className = this.#class.badge.inner;
    
    this.valueNode = document.createElement('SPAN');
    this.valueNode.classList.add(this.#class.counter.node);
    this.valueNode.innerHTML = parseInt(this.options.start) || 0;

    this.badgeNode.appendChild(this.badgeInnerNode);
    this.badgeInnerNode.appendChild(this.valueNode);
    this.element.appendChild(this.badgeNode);
    
    this.newValueNode = this.valueNode.cloneNode();
    this.valueNode.classList.add(this.#class.counter.old);
    this.newValueNode.classList.add(this.#class.counter.new);
    this.valueNode.after(this.newValueNode);
    
    // set events
    this.badgeInnerNode.addEventListener('transitionend', (e) => {
      this.badgeNode.classList.remove(this.#class.badge.animate);
      this.valueNode.innerHTML = this.value;
    });
  }

  set(n) {
    n = n || 0;
    if (n === this.get()) return;

    // If value is somehow become wrong, wrong type, less than 0, or NaN. 
    // Then hide everything and log an error.
    if (typeof n != 'number' || n < 0 || isNaN(n)) {
      console.error('Wrong type or n(' + n + ') is less then 0!');
      this.hide();

      return false;
    }

    // set new value and calc direction.
    this.direction = this.value === n ? 0 : this.value < n ? 1 : -1;
    this.value = n;

    // hide, if counter goes 0 or lower
    if (n === 0) {
      this.hide();
      this.valueNode.innerHTML = '';
      this.newValueNode.innerHTML = '';

      return false;
    }

    // show counter if it's not visible yet.
    this.show();
    
    // if counter goes over 99 show only spot insted of numbers
    this.tintToggle(n > 99);

    // set direction and run animation
    this.animate();
  }
  
  animate() {
    this.newValueNode.innerHTML = this.value;
    this.element.style.setProperty('--direction', this.direction);
    setTimeout(()=>{
      this.badgeNode.classList.add(this.#class.badge.animate);
    }, 1);
  }
  
  tintToggle(flag) {
    this.isTinted = flag;
    this.badgeNode.classList[flag ? 'add' : 'remove'](this.#class.badge.limit);
  }
  
  show() {
    if (this.isVisible) return;
    this.isVisible = true;
    this.badgeNode.classList.add(this.#class.badge.active);
  }
  
  hide() {
    if (!this.isVisible) return;
    this.isVisible = false;
    this.badgeNode.classList.remove(this.#class.badge.active);
  }

  get() {
    return this.value;
  }

  decrease(n) {
    n = n || 1;
    this.direction = -1;
    this.value = this.get() || 0;

    if (this.value - n < 0) {
      return false;
    }

    this.set(this.value - n);
  }

  increase(n) {
    n = n || 1;
    this.direction = 1;
    this.value = this.get() || 0;

    if (this.value + n < 0) {
      return false;
    }

    this.set(this.value + n);
  }
}



// API usage example:
let customNotification = new Badge(document.querySelector('.notification'), {
  start: 21, // default 0
  animationSpeed: 350, // default 200
});
let customCart = new Badge(document.querySelector('.cart')); // default option

document.querySelector('.increase').addEventListener('click', () => {
  customNotification.increase();
  customCart.increase();
});
document.querySelector('.decrease').addEventListener('click', () => {
  customNotification.decrease();
  customCart.decrease();
});
document.querySelector('.hide').addEventListener('click', () => {
  customNotification.set(0);
  customCart.set(0);
});
document.querySelector('.set').addEventListener('click', () => {
  customNotification.set(100);
  customCart.set(100);
});
document.querySelector('.get').addEventListener('click', () => {
  window.alert('Notifications count = ' + customNotification.get() + '; Cart count = ' + customCart.get());
});

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://codepen.io/ykosinets/pen/bNbOppq.js