Back to the library

Badge — notification count

Variant: Notification count

The unread count on an icon, shown across its whole life: it scales in on the first notification, ticks one to two on the next, and clears — because a count badge that only ever grows is documentation of half a control. The increment is the piece: an anticipation squash to 0.86 before an overshoot to 1.16, the one place a badge earns a bounce, since a number that changes with no wind-up reads as a swap rather than an arrival. The overshoot lives in the keyframe values instead of a springy curve, so the remix speed knob can never fling it into a clip, and the digit is a registered integer that jumps under cover of the pop so it can never land a frame off the badge carrying it. The bell answers each event with a single decaying wobble rather than ringing the whole loop, and the badge grows from its lower-left corner where it meets the icon, so it reads as pinned rather than floating in.

statusbadgecounter

The source

A complete HTML document. Paste it into an empty .html file and it plays — no stylesheet, script, font, or image to fetch.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="animation-knobs" content="overshoot: ok" />
    <title>Badge — notification count</title>
    <style>
      /* A registered integer is the count: the browser holds it, counter()
         renders it, and one steps-style jump on the bump frame swaps the digit
         under cover of the pop — so there is no second timeline for the number
         to drift against the badge that carries it. */
      @property --_n {
        syntax: '<integer>';
        initial-value: 1;
        inherits: false;
      }

      :root {
        /* Neutrals — fixed, never remixed. */
        --ivory: #faf9f5;
        --paper: #ffffff;
        --oat: #e3dacc;
        --slate: #141413;

        /* Knobs — the remix API. A knob exists iff declared here and used below. */
        --hue: 38.8;
        --lift: 0;
        --chroma: 1;
        --speed: 1;

        /* Accents — derived from the knobs with the family's offsets baked in,
           so one hue roll moves them together. Defaults reproduce the original hex. */
        --clay: oklch(calc(0.6724 + var(--lift)) calc(0.1308 * var(--chroma)) var(--hue));
        --clay-d: oklch(calc(0.5797 + var(--lift)) calc(0.127 * var(--chroma)) var(--hue));
        --olive: oklch(
          calc(0.6118 + var(--lift)) calc(0.0713 * var(--chroma)) calc(var(--hue) + 88.3)
        );
        --rust: oklch(
          calc(0.5408 + var(--lift)) calc(0.1357 * var(--chroma)) calc(var(--hue) - 10.3)
        );

        /* Clock — 4.6s: a badge appearing (0→1), a beat, an increment (1→2), a
           long read, a dismissal, and a dead window before it re-arms. Most of
           the loop is the two holds, because a count badge spends its life sat
           still and only occasionally changes. */
        --cycle: 4.6s;
        --t: calc(var(--cycle) / var(--speed));

        --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
      }

      * {
        box-sizing: border-box;
        margin: 0;
        padding: 0;
      }

      body {
        min-height: 100vh;
        display: grid;
        place-items: center;
        background: var(--ivory);
        font-family:
          system-ui,
          -apple-system,
          'Segoe UI',
          Roboto,
          sans-serif;
        color: var(--slate);
      }

      /* Extra room so the badge's overshoot never meets the frame edge. */
      .bell {
        position: relative;
        width: 46px;
        height: 46px;
        padding: 6px;
      }

      .bell svg {
        width: 100%;
        height: 100%;
        display: block;
        fill: none;
        stroke: var(--slate);
        stroke-width: 1.6;
        stroke-linecap: round;
        stroke-linejoin: round;
        transform-origin: 50% 12%; /* rung from the crown, the way a bell hangs */
        animation: ring var(--t) var(--ease-out) infinite;
      }

      .badge {
        position: absolute;
        top: 0;
        right: 0;
        display: grid;
        place-items: center;
        min-width: 19px;
        height: 19px;
        padding: 0 5px;
        border-radius: 999px; /* a pill/disc — half its own height, never --round */
        background: var(--clay);
        box-shadow: 0 0 0 2px var(--ivory); /* cut out of the page behind it */
        color: var(--paper);
        font-size: 12px;
        font-weight: 700;
        line-height: 1;
        font-variant-numeric: tabular-nums;
        transform: scale(0);
        /* Grown from its lower-left corner, where it meets the bell body, so it
           reads as pinned to the icon rather than floating in from nowhere. */
        transform-origin: 15% 85%;
        animation:
          bump var(--t) var(--ease-out) infinite,
          count var(--t) linear infinite;
        /* The counter is reset on the animated element itself, because --_n does
           not inherit — the ::after reads the counter, not the property. */
        counter-reset: n var(--_n);
      }

      .badge::after {
        content: counter(n);
      }

      /* Constant either side of an instant jump — the 0.01% ramp is under half a
         millisecond, so the digit reads as switching rather than interpolating. */
      @keyframes count {
        0%,
        44% {
          --_n: 1;
        }
        44.01%,
        100% {
          --_n: 2;
        }
      }

      /* Appearance is a scale-in pop; the increment is an anticipation squash to
         0.86 followed by an overshoot to 1.16 before it settles. The overshoot
         lives in the keyframe values rather than a springy curve, so the remix
         speed knob can never fling it into a clip. Dismissal is a quick fold
         back to nothing, faster than either arrival — an exit gets out of the way. */
      @keyframes bump {
        0%,
        6% {
          transform: scale(0);
          opacity: 0;
        }
        11% {
          transform: scale(1.16);
          opacity: 1;
        }
        16%,
        38% {
          transform: scale(1);
          opacity: 1;
        }
        42% {
          transform: scale(0.86);
        }
        48% {
          transform: scale(1.16);
        }
        53%,
        84% {
          transform: scale(1);
          opacity: 1;
        }
        90%,
        100% {
          transform: scale(0);
          opacity: 0;
        }
      }

      /* The bell answers each event with a single decaying wobble — a reaction,
         not a chime that rings the whole loop. It leans on the frames the badge
         appears (6%) and increments (42%) and is still otherwise. */
      @keyframes ring {
        0%,
        6%,
        42%,
        62%,
        100% {
          transform: rotate(0deg);
        }
        9%,
        45% {
          transform: rotate(9deg);
        }
        13%,
        50% {
          transform: rotate(-6deg);
        }
        17%,
        55% {
          transform: rotate(3deg);
        }
      }

      @media (prefers-reduced-motion: reduce) {
        /* The count still changes — it is a value, not a thing moving, and
           dropping it would leave the badge making no claim. The scale pop, the
           wobble, and the fold-away all go; appearance and dismissal become
           opacity alone, and the cycle stretches so the change can be read
           rather than caught. */
        .bell svg {
          animation: none;
          transform: none;
        }

        .badge {
          transform: scale(1);
          transform-origin: 50% 50%;
          animation-name: bump-fade, count;
          animation-duration: calc(var(--t) * 1.5), calc(var(--t) * 1.5);
        }

        @keyframes bump-fade {
          0%,
          6% {
            opacity: 0;
          }
          12%,
          84% {
            opacity: 1;
          }
          90%,
          100% {
            opacity: 0;
          }
        }
      }
    </style>
  </head>
  <body>
    <span
      class="bell"
      role="img"
      aria-label="A bell icon whose unread badge appears, ticks from one to two with a pop, then clears"
    >
      <svg viewBox="0 0 24 24" aria-hidden="true">
        <path d="M18 8a6 6 0 1 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" />
        <path d="M13.7 21a2 2 0 0 1-3.4 0" />
      </svg>
      <span class="badge"></span>
    </span>
  </body>
</html>

The variant family

Same idea, one axis moved. Compare them side by side before you commit.