SolutionsCase StudiesAI ToolsResourcesAboutContact
Get Your Free AI Automation Audit Book a Free 30-Minute Strategy Call
Playground~/frontend Open editor

~/css/number-counters

7 CSS Number Counter Animations

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.

01. Count Up When Visible

CounterIntersectionObserver

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>
.nc1 { width: 260px; max-width: 100%; padding: 24px; box-sizing: border-box; border: 1px solid #e4e7ec; border-radius: 16px; background: #fff; text-align: center; font-family: Inter, system-ui, sans-serif; }
.nc1 p { display: grid; margin: 0; }
.nc1 b { color: #101828; font-size: 42px; font-variant-numeric: tabular-nums; }
.nc1 span { color: #475467; font-size: 14px; }
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.

View code
<div class="nc2"><p class="lab">Visitors today</p><p class="num"><span class="sr">48,213</span><span class="digits" aria-hidden="true" data-v="48213"></span></p><button>Add a visitor</button></div>
.nc2 { width: 260px; max-width: 100%; padding: 20px; box-sizing: border-box; border-radius: 16px; background: #111827; text-align: center; font-family: Inter, system-ui, sans-serif; }
.nc2 .lab { margin: 0 0 8px; color: #94a3b8; font-size: 13px; }
.nc2 .num { margin: 0 0 12px; }
.nc2 .digits { display: inline-flex; gap: 3px; }
.nc2 .d { width: 30px; height: 44px; overflow: hidden; border-radius: 6px; background: #1f2937; }
.nc2 .d i { display: flex; flex-direction: column; font: 800 30px/44px "JetBrains Mono", ui-monospace, monospace; font-style: normal; color: #fbbf24; transition: transform .8s cubic-bezier(.2,.8,.2,1); }
.nc2 .sep { color: #fbbf24; font: 800 30px/44px "JetBrains Mono", ui-monospace, monospace; }
.nc2 .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
.nc2 button { padding: 8px 14px; border: 1px solid #374151; border-radius: 8px; background: transparent; color: #e5e7eb; font: 600 13px Inter, system-ui, sans-serif; cursor: pointer; }
.nc2 button:focus-visible { outline: 2px solid #fbbf24; outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { .nc2 .d i { transition: none; } }
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.

View code
<p class="nc3"><span class="n" aria-hidden="true"></span><span class="sr">98%</span> <span class="l">customer satisfaction</span></p>
@property --nc3 { syntax: "<integer>"; initial-value: 0; inherits: false; }
.nc3 { display: grid; justify-items: center; margin: 0; font-family: Inter, system-ui, sans-serif; }
.nc3 .n { --nc3: 98; counter-reset: n var(--nc3); color: #6941c6; font-size: 56px; font-weight: 800; animation: nc3 2s ease-out; }
.nc3 .n::after { content: counter(n) "%"; }
.nc3 .l { color: #475467; font-size: 14px; }
.nc3 .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
@keyframes nc3 { from { --nc3: 0; } }
@media (prefers-reduced-motion: reduce) { .nc3 .n { animation: none; } }

04. Stats Row with Suffixes

CounterStats

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.

View code
<ul class="nc4"><li><b data-to="2.4" data-dec="1" data-suf="k">2.4k</b><span>Active shops</span></li><li><b data-to="98" data-dec="0" data-suf="%">98%</b><span>Uptime last year</span></li><li><b data-to="4.9" data-dec="1" data-suf="">4.9</b><span>Average rating</span></li></ul>
.nc4 { display: flex; flex-wrap: wrap; justify-content: center; gap: 10px; margin: 0; padding: 0; list-style: none; font-family: Inter, system-ui, sans-serif; box-sizing: border-box; max-width: 100%; }
.nc4 li { display: grid; min-width: 90px; padding: 14px 10px; border-radius: 14px; background: #161d31; text-align: center; }
.nc4 b { color: #fff; font-size: 28px; font-variant-numeric: tabular-nums; }
.nc4 span { color: #94a3b8; font-size: 12px; }
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.

View code
<div class="nc5"><fieldset><legend class="sr">Billing period</legend><label><input type="radio" name="nc5" value="12" checked> Monthly</label><label><input type="radio" name="nc5" value="9"> Yearly</label></fieldset><p class="pr"><span aria-live="polite">$<b>12</b></span> / month</p></div>
.nc5 { width: 260px; max-width: 100%; padding: 20px; box-sizing: border-box; border: 1px solid #e4e7ec; border-radius: 16px; background: #fff; text-align: center; font-family: Inter, system-ui, sans-serif; }
.nc5 fieldset { display: inline-flex; min-inline-size: 0; margin: 0 0 14px; padding: 4px; border: 0; border-radius: 999px; background: #f2f4f7; }
.nc5 label { padding: 8px 14px; border-radius: 999px; color: #475467; font-size: 14px; font-weight: 600; cursor: pointer; }
.nc5 label:has(:checked) { background: #fff; color: #101828; box-shadow: 0 1px 3px rgba(16,24,40,.15); }
.nc5 label:has(:focus-visible) { outline: 2px solid #528bff; outline-offset: 2px; }
.nc5 input { position: absolute; opacity: 0; }
.nc5 .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
.nc5 .pr { margin: 0; color: #475467; } .nc5 .pr span { color: #101828; font-size: 40px; font-weight: 800; font-variant-numeric: tabular-nums; }
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.

View code
<div class="nc6" role="img" aria-label="Profile 72 percent complete"><span></span></div>
@property --nc6 { syntax: "<integer>"; initial-value: 0; inherits: true; }
.nc6 { --nc6: 72; display: grid; place-items: center; width: 140px; height: 140px; border-radius: 50%; background: conic-gradient(#34d399 calc(var(--nc6) * 1%), #1f2937 0); animation: nc6 1.8s ease-out; }
.nc6 span { display: grid; place-items: center; width: 112px; height: 112px; border-radius: 50%; background: #0e1118; counter-reset: p var(--nc6); color: #ecfdf5; font: 800 30px Inter, system-ui, sans-serif; }
.nc6 span::after { content: counter(p) "%"; }
@keyframes nc6 { from { --nc6: 0; } }
@media (prefers-reduced-motion: reduce) { .nc6 { animation: none; } }

07. Currency Counter with Intl

CounterCurrency

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>
.nc7 { width: 260px; max-width: 100%; padding: 20px; box-sizing: border-box; border: 1px solid #e4e7ec; border-radius: 16px; background: #fff; font-family: Inter, system-ui, sans-serif; }
.nc7 .l { margin: 0 0 4px; color: #475467; font-size: 13px; font-weight: 600; }
.nc7 .v { margin: 0; color: #067647; font-size: 32px; font-weight: 800; font-variant-numeric: tabular-nums; }
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.