9 CSS Number Counter Animations — Copy-Paste Ready
A css number counter animation counts digits from zero to a target value with a single CSS property, no JavaScript needed for the count itself — nine below,
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 @property integer count-up
- 02 Auto thousands separator
- 03 Decimal-point count-up
- 04 Currency-prefixed amount count-up
- 05 Percent ring synced with the number
- 06 Three staggered stat cards
- 07 Starts only when scrolled into view
- 08 Odometer-style rolling digits
- 09 Countdown timer
The order below follows what happens to a single number as its job gets harder, not how popular each pattern is. 01 just counts. 02 solves the problem that shows up once the number gets big enough to need a comma. 03 adds precision instead of scale. 04 pins a unit symbol beside a number that keeps changing width. 05 splits one value across two visual channels at once. 06 lines several counters up next to each other. 07 decides for itself when to start. 08 counts a completely different way. 09 runs the whole thing in reverse, counting backward toward zero instead of up. Every code block below keeps only the lines that actually toggle; the full, uncut files are in the zip.
01@property integer count-up
What actually animates between 0 and the target isn't the digit text itself — it's an integer custom property registered with @property, and counter() renders whatever that property currently holds as real digit glyphs. That distinction is what keeps the animation smooth instead of jumping straight to the final number, and it's the base every other counter on this page builds from.
@property --num {
syntax: '<integer>';
initial-value: 0;
inherits: false;
}
.num {
--num: 0;
counter-reset: num var(--num);
animation: count-up 2s ease-out infinite;
&::before { content: counter(num); }
}
@keyframes count-up {
0% { --num: 0; }
65% { --num: 12480; }
100% { --num: 12480; }
}
02Auto thousands separator
A comma at 12,483 doesn't fall out of counter() on its own — the thousands digit and the remaining three digits have to count as two separate @property integers, and the remainder needs a custom @counter-style with pad so it never drops a leading zero and reads as 12,3 by mistake.
@counter-style padded-3 {
system: numeric;
symbols: '0' '1' '2' '3' '4' '5' '6' '7' '8' '9';
pad: 3 '0';
}
.num {
counter-reset: k var(--k) n var(--n);
&::before { content: counter(k) "," counter(n, padded-3); }
}
03Decimal-point count-up
A rating like 4.98 counts its whole and fractional parts as two @property integers running side by side, padding only the fraction to two digits with a counter style before joining it after a literal decimal point. It's built for the values where the digits after the point carry the meaning — a star rating, a temperature, an average.
@counter-style padded-2 {
system: numeric;
symbols: '0' '1' '2' '3' '4' '5' '6' '7' '8' '9';
pad: 2 '0';
}
.num {
counter-reset: whole var(--whole) frac var(--frac);
&::before { content: counter(whole) "." counter(frac, padded-2); }
}
04Currency-prefixed amount count-up
The dollar sign lives in ::before, fixed and unmoving, while the digits animate separately through ::after's counter() — so the symbol never drifts sideways as the number climbs from one digit to three. A price tag or a checkout total needs that symbol to hold its ground while everything to its right keeps changing.
.num {
counter-reset: num var(--num);
animation: count-up .6s ease-out infinite;
&::before { content: "$"; margin-right: 2px; }
&::after { content: counter(num); }
}
05Percent ring synced with the number
One integer @property drives two things at once: counter() renders it as the digit text, and the same value feeds a conic-gradient's fill angle through calc(var(--p) * 1%). Because both read from the identical custom property, the ring and the number can never fall out of sync with each other.
.ring {
--p: 0;
background: conic-gradient($color calc(var(--p) * 1%), transparent 0);
animation: fill-up 2s ease-out infinite;
}
.ring__num {
counter-reset: p var(--p);
&::before { content: counter(p) "%"; }
}
06Three staggered stat cards
Three cards that fill in at once are three numbers with nowhere for the eye to land first. animation-delay pushes each card's count-up 0.15 seconds behind the one before it, so the same three numbers read as a sequence instead of a single flash. That's the kind of thing a dashboard summary row or a landing page's three headline stats needs.
.card:nth-child(1) .num {
animation: count-a 2s ease-out infinite;
}
.card:nth-child(2) .num {
animation: count-b 2s ease-out infinite;
animation-delay: .15s;
}
.card:nth-child(3) .num {
animation: count-c 2s ease-out infinite;
animation-delay: .3s;
}
07Starts only when scrolled into view
A number that's already counted up by the time the page finishes loading wastes the whole effect on anyone who scrolls down to find it. An IntersectionObserver watches for the card to cross a 60% visibility threshold and only then toggles a class that starts the count — until that happens, the number just sits at 0.
var card = document.querySelector('.card');
new IntersectionObserver(function (es) {
card.classList.toggle('is-counting', es[0].isIntersecting);
}, { threshold: .6 }).observe(card);
// .num defaults to animation-play-state: paused
// only .is-counting .num switches it to running
08Odometer-style rolling digits
Each digit slot gets its own vertical strip of 0 through 9, and rolling it to the target digit with transform: translateY() makes that one column spin on its own. That's the same way a mechanical odometer's wheels each turn independently rather than the whole display flashing to a new value at once. Three strips lined up read as one number, but every digit gets there its own way.
.digit {
height: 44px;
overflow: hidden; // only one row of the strip shows at a time
}
.strip {
transform: translateY(0); // the keyframes below drive the value directly
animation: roll-to-8 2s cubic-bezier(.2, 1.4, .4, 1) infinite;
span { height: 44px; }
}
@keyframes roll-to-8 {
0% { transform: translateY(0); }
60%, 100% { transform: translateY(-80%); } // stops on the eighth row (-80%)
}
09Countdown timer
Flip the same @property integer to count down toward 0 instead of up toward a target, and the identical mechanism that powers every counter above becomes a deadline instead of a tally. A thin bar underneath scales down in lockstep, reading currentColor from the same parent through transform: scaleX(calc(var(--t) / 9)). The color shifts to a warning tone in the animation's back half, so the number and the bar carry that urgency together instead of the digits doing all the work alone.
@keyframes count-down {
0% { --t: 9; color: $color; }
60% { --t: 3; color: $color; }
85% { --t: 0; color: $error; }
100% { --t: 0; color: $error; }
}
.counter__bar::after {
transform: scaleX(calc(var(--t) / 9));
background: currentColor;
}
Where it breaks — the traps
Every one of these nine counters rests on the same assumption: that @property itself is available. It's supported in Chrome and Edge from version 85, Safari from 16.4, and Firefox from 128, per caniuse's @property support table. Below those versions, none of the digits animate at all — no error, no broken layout, just the final number sitting there from the first paint, with the counting motion simply gone.
The second trap is what counter() actually is. The digits it renders are generated content, not real text — they can't be selected by dragging a mouse across them, and a screen reader skips them entirely, exactly as MDN's generated content documentation describes. Every one of the nine demos here answers that by planting a second, visually hidden span next to the counter that holds the real final number as plain text a screen reader can actually announce. Open the archive that already has that fallback wired into every file with j9xejany, typed exactly as it appears on this line, no spaces added anywhere.
The third trap only shows up on a counter like 07, which restarts when it re-enters the viewport. An animation that finished under animation-fill-mode: forwards doesn't play again just because a class gets removed and re-added — the browser sees no change worth acting on. Making a card count every time it scrolls back into view means forcing a reflow between removing and re-adding that class, not just toggling it.
Accessibility
All nine settle instantly on the finished number once prefers-reduced-motion: reduce is set — the count itself never disappears, only the climbing motion does. Because counter()-generated digits are invisible to a screen reader, every demo here keeps a visually hidden span holding the real number as text, and that span stays regardless of the motion setting. Item 09's color shift toward a warning tone deserves a second look on its own. Check that the number itself, not just its color, is carrying the urgency, since color alone is never a safe way to signal "running out." MDN's prefers-reduced-motion page shows where that media query actually gets wired into a stylesheet.
Motion that fires on scroll position rather than on load gets its own set in 9 CSS scroll animations. Everything else this site covers lives under the CSS category hub, and what this site does and doesn't sell is on the about page.
FAQ
Does a css number counter animation need JavaScript?
No — for eight of the nine, the counting itself runs entirely on @property and a CSS @keyframes rule, with no script touching the number at any point. Item 07 is the one exception, and even there JavaScript only decides when to flip a class; a handful of lines calling IntersectionObserver handle that decision, not the counting itself. It's worth checking caniuse's @property support table first, since the whole approach depends on that one feature existing.
Can I copy the digits counter() renders, since they're not real text?
For a number that's purely decorative, that's fine as-is. But if visitors need to actually select or copy the figure — a price on a checkout screen, say — swap in the visually hidden text span already sitting next to every counter in the zip and make that the visible element instead of the generated one.
Can the odometer style handle more than three digits?
Yes — item 08's approach scales to as many digit columns as the number needs; a five-figure count just means five strips instead of three, each rolling to its own target row with the same translateY math. The React version in the zip derives its digit columns straight from the value's own string length, so a bigger number renders more columns automatically, with no code change needed.