2 Pure CSS5 with JSPublished 2026-10-05Free · no sign-up
Seven ways to animate numbers: a count-up that starts when it scrolls into view, rolling odometer digits, a pure CSS counter built on @property, a row of stats with suffixes, a price that counts between plans, a ring and percentage driven by one property and a currency counter formatted with Intl.
The number counts up from zero with an ease-out curve when it scrolls into view. The final value is already in the HTML, so search engines, screen readers and visitors without JavaScript see the real figure; the animation only rewrites it on screen.
View code
<div class="nc1"><p><b data-to="12480">12,480</b><span>orders shipped this year</span></p></div>
var el = document.querySelector('.nc1 b'), to = +el.dataset.to, fmt = new Intl.NumberFormat('en-GB');
if (!matchMedia('(prefers-reduced-motion: reduce)').matches) {
var io = new IntersectionObserver(function (es) { if (!es[0].isIntersecting) return; io.disconnect(); var t0 = performance.now();
(function step(t) { var k = Math.min(1, (t - t0) / 1600), e = 1 - Math.pow(1 - k, 3); el.textContent = fmt.format(Math.round(to * e)); if (k < 1) requestAnimationFrame(step); })(t0); });
io.observe(el);
}
02. Rolling Odometer Digits
CounterOdometer
Each digit is a column of 0 to 9 that rolls to its value with translateY, like a car odometer. The columns are aria-hidden and the real number is in a visually hidden span next to them, so the value is read once and correctly.
var box = document.querySelector('.nc2 .digits'), sr = document.querySelector('.nc2 .sr'), v = +box.dataset.v;
function render(n) {
var s = n.toLocaleString('en-GB'); sr.textContent = s;
if (box.children.length !== s.length) { box.innerHTML = ''; s.split('').forEach(function (c) { var e; if (c === ',') { e = document.createElement('span'); e.className = 'sep'; e.textContent = ','; } else { e = document.createElement('span'); e.className = 'd'; e.innerHTML = '<i>0<br>1<br>2<br>3<br>4<br>5<br>6<br>7<br>8<br>9</i>'; } box.appendChild(e); }); }
s.split('').forEach(function (c, k) { var i = box.children[k].querySelector('i'); if (i) i.style.transform = 'translateY(' + (-44 * +c) + 'px)'; });
}
render(0); requestAnimationFrame(function () { requestAnimationFrame(function () { render(v); }); });
document.querySelector('.nc2 button').onclick = function () { v++; render(v); };
03. Pure CSS Counter with @property
CounterNo JS
A count from 0 to 98 with no JavaScript: an integer custom property registered with @property is animated, and counter-reset with counter() prints it in a pseudo-element. Because the number lives in CSS, the real value is repeated in visually hidden text.
Three statistics count up together, each with its own suffix and decimals: 2.4k, 98% and 4.9. The script reads data attributes for the target, decimals and suffix, so one function drives any number of stats.
if (!matchMedia('(prefers-reduced-motion: reduce)').matches) document.querySelectorAll('.nc4 b').forEach(function (el) {
var to = +el.dataset.to, dec = +el.dataset.dec, suf = el.dataset.suf, t0 = performance.now();
(function step(t) { var k = Math.min(1, (t - t0) / 1400), e = 1 - Math.pow(1 - k, 3); el.textContent = (to * e).toFixed(dec) + suf; if (k < 1) requestAnimationFrame(step); })(t0);
});
05. Price That Counts Between Plans
CounterPricing
Switching between monthly and yearly billing animates the price from one value to the other instead of swapping it. The toggle is a pair of radio buttons, and the price element is a polite live region so the new price is announced once it settles.
var b = document.querySelector('.nc5 b'), cur = 12, id;
document.querySelector('.nc5 fieldset').addEventListener('change', function (e) {
var to = +e.target.value, from = cur, t0 = performance.now(); cur = to; cancelAnimationFrame(id);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) { b.textContent = to; return; }
(function step(t) { var k = Math.min(1, (t - t0) / 500); b.textContent = Math.round(from + (to - from) * k); if (k < 1) id = requestAnimationFrame(step); })(t0);
});
06. Percentage Ring That Fills
CounterRing
The number and its ring animate together: a single @property percentage drives both the conic-gradient fill and the counter() in the centre, so they can never drift apart. It is pure CSS, with the value repeated as text for assistive technology.
Revenue counts up and is formatted on every frame with Intl.NumberFormat, so the currency symbol, thousands separators and decimals are right for the chosen locale. Change the locale and currency in the script to see euros or yen formatted correctly.
View code
<div class="nc7"><p class="l">Revenue this month</p><p class="v" data-to="84250.5">$84,250.50</p></div>
var el = document.querySelector('.nc7 .v'), to = +el.dataset.to, f = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
if (!matchMedia('(prefers-reduced-motion: reduce)').matches) { var t0 = performance.now();
(function step(t) { var k = Math.min(1, (t - t0) / 1500), e = 1 - Math.pow(1 - k, 4); el.textContent = f.format(to * e); if (k < 1) requestAnimationFrame(step); })(t0); }
About these CSS Number Counter Animations
Put the real number in the HTML
Write the final value in the markup and only animate it on screen. Search engines, screen readers, people without JavaScript and anyone with reduced motion then see the correct figure straight away.
Ease out, do not run linear
Counters feel natural when they slow down near the end. An ease-out curve such as 1 - (1 - t)³ applied to the progress makes the last digits settle rather than stop abruptly.
Stop the width jumping
font-variant-numeric: tabular-nums makes every digit the same width, so a counter does not wobble as 1 changes to 8.
CSS-only counting
Register an integer with @property, animate it in keyframes and print it with counter-reset and counter() in a pseudo-element. Generated content is not reliably read aloud, so repeat the value in hidden text.
Frequently asked questions
How do I start the count when it is visible?
Use an IntersectionObserver and start the animation the first time the element intersects, then disconnect it.
Can I format thousands and currency?
Yes. Intl.NumberFormat handles separators, decimals and currency symbols for any locale on each frame.
Are animated numbers honest?
Only if the final figure is real. Never animate to a number you cannot back up.