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.
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.