GODRICH

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

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.

@propertysyntax: '<integer>'counter()
@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-stylepad: 3 '0'counter(k) ',' counter(n)
@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.

value×100pad:2소수점 둘째 자리
@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.

::before content통화 기호 고정$dur-enter
.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.

conic-gradient하나의 @property링+숫자 동기
.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.

animation-delay0.15초 간격카드 3장
.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.

IntersectionObserverthreshold .6보이기 전 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.

translateY 스트립자릿수별 독립기계식 계기판
.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.

역방향 카운트0으로 수렴경고색 전환
@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.

Enter the archive password

The password is inside this article. You will find it as you read.