Most demos need a newer browser

They use :has() and container queries. Update your browser, or on an iPhone update iOS to 16.4 or later.

CSS Animated Number Counter

Animated number counter in CSS alone, using @property, keyframes, and content:counter. Variants hold until clicked and pair the count with a progress bar.

Level
Advanced
Published
Updated
Reading time
9 min

Try it

3 live demos

Examples
Pure CSS
Rating

Counting up to a target

Other ways to build it

Signups this month

Hold the count until a click

See the code

Build it

6 snippets to copy

CSS Animated Number Counter

HTML

<input type="checkbox" id="replay" class="counter-replay-toggle">
<div class="counter-grid">
  <div class="counter-item">
    <div class="counter-value" style="--target: 26"></div>
    <div class="counter-label">Examples</div>
  </div>
  <div class="counter-item">
    <div class="counter-value" style="--target: 100" data-suffix="%"></div>
    <div class="counter-label">Pure CSS</div>
  </div>
  <div class="counter-item">
    <div class="counter-value counter-decimal" style="--target: 4; --decimal: 8"></div>
    <div class="counter-label">Rating</div>
  </div>
</div>
<label for="replay" class="counter-replay">Replay</label>

CSS

/* Registering the property is what makes it animatable */
@property --target {
  syntax: "<integer>";
  initial-value: 0;
  inherits: false;
}

@property --decimal {
  syntax: "<integer>";
  initial-value: 0;
  inherits: false;
}

@keyframes count-up {
  from { --target: 0; }
}

@keyframes count-frac {
  from { --decimal: 0; }
}

.counter-grid {
  display: flex;
  gap: 2rem;
  flex-wrap: wrap;
  justify-content: center;
}

.counter-item {
  text-align: center;
}

.counter-value {
  font-size: 2.5rem;
  font-weight: 800;
  color: #b8ff57;
  counter-reset: num var(--target);
  animation: count-up 2s ease-out forwards;
}

.counter-value::after {
  content: counter(num);
}

/* Optional unit, taken straight off the element */
.counter-value[data-suffix]::after {
  content: counter(num) attr(data-suffix);
}

/* Two counters running at once gives a decimal */
.counter-value.counter-decimal {
  counter-reset: num var(--target) frac var(--decimal);
  animation:
    count-up 2s ease-out forwards,
    count-frac 2s ease-out forwards;
}

.counter-value.counter-decimal::after {
  content: counter(num) "." counter(frac);
}

.counter-label {
  font-size: .85rem;
  color: #88888f;
  margin-top: .25rem;
}

/* Replay: the checkbox swaps the counters onto a second,
   identical set of keyframes. A new animation name starts
   the animation over, so every click replays the count */
@keyframes count-up-again {
  from { --target: 0; }
}

@keyframes count-frac-again {
  from { --decimal: 0; }
}

.counter-replay-toggle:checked ~ .counter-grid .counter-value {
  animation-name: count-up-again;
}

.counter-replay-toggle:checked ~ .counter-grid .counter-decimal {
  animation-name: count-up-again, count-frac-again;
}

/* the box stays in the page for the keyboard and
   screen readers; the label is the button you see */
.counter-replay-toggle {
  position: absolute;
  width: 1px;
  height: 1px;
  opacity: 0;
}

.counter-replay {
  display: inline-block;
  margin-top: 1.75rem;
  padding: .45rem .95rem;
  border: 1px solid #2a2a2d;
  border-radius: 999px;
  background: #1c1c1e;
  color: #f0f0f0;
  font-size: .8rem;
  font-weight: 600;
  cursor: pointer;
}

.counter-replay:hover {
  border-color: #b8ff57;
  color: #b8ff57;
}

.counter-replay:active {
  transform: translateY(1px);
}

.counter-replay-toggle:focus-visible ~ .counter-replay {
  outline: 2px solid #b8ff57;
  outline-offset: 3px;
}

Hold the count until a click

A counter that fires on page load has usually finished before the reader reaches it. Parking the animation with animation-play-state: paused keeps it frozen on its first keyframe, which is zero, and a checkbox flips it to running. The label is what receives the click, so this works on touch and with the space bar, and the focus ring goes on the label because the input driving it is hidden. Pressing it again pauses the count wherever it happens to be.

HTML

<div class="nc-manual">
  <input type="checkbox" id="run" class="nc-manual-toggle">
  <div class="nc-manual-value"></div>
  <div class="counter-label">Signups this month</div>
  <label for="run" class="nc-manual-btn">Start / pause</label>
</div>

CSS

@property --nc-manual {
  syntax: "<integer>";
  initial-value: 0;
  inherits: false;
}

@keyframes nc-count-manual {
  from { --nc-manual: 0; }
}

.nc-manual-toggle {
  position: absolute;
  opacity: 0;
  width: 1px;
  height: 1px;
}

.nc-manual-value {
  /* the real figure lives here, so the count ends on it */
  --nc-manual: 1482;
  font-size: 2.75rem;
  font-weight: 800;
  color: #b8ff57;
  counter-reset: total var(--nc-manual);
  animation: nc-count-manual 1.8s linear forwards;
  /* frozen on the first keyframe, which reads 0 */
  animation-play-state: paused;
}

.nc-manual-value::after {
  content: counter(total);
}

.nc-manual-toggle:checked ~ .nc-manual-value {
  animation-play-state: running;
}

/* the input is hidden, so the focus ring goes on the label */
.nc-manual-toggle:focus-visible ~ .nc-manual-btn {
  outline: 2px solid #b8ff57;
  outline-offset: 3px;
}

One property driving a number and a bar

Registering the property with inherits: true lets a single animation on the wrapper feed every child that reads it. The percentage text takes it through counter-reset and the fill takes it through calc(var(--nc-pct) * 1%), so the two can never drift apart the way two separate animations would. The keyframes hold at 0 and at 100 for part of the cycle, which is why this one uses linear: an easing curve would restart at each of those four stops and the number would visibly stall on the way up.

HTML

<div class="nc-meter">
  <div class="nc-meter-head">
    <span>Uploading</span>
    <span class="nc-meter-value"></span>
  </div>
  <div class="nc-meter-track"><div class="nc-meter-fill"></div></div>
</div>

CSS

/* inherits: true is the point; children read the animated value */
@property --nc-pct {
  syntax: "<integer>";
  initial-value: 0;
  inherits: true;
}

@keyframes nc-fill {
  0%, 10%   { --nc-pct: 0; }
  70%, 100% { --nc-pct: 100; }
}

.nc-meter {
  width: min(320px, 100%);
  /* linear, because the curve would restart at every stop */
  animation: nc-fill 3.2s linear infinite;
}

.nc-meter-value {
  counter-reset: pct var(--nc-pct);
  color: #b8ff57;
  font-weight: 700;
}

.nc-meter-value::after {
  content: counter(pct) "%";
}

.nc-meter-track {
  height: 10px;
  border-radius: 999px;
  background: #1c1c1e;
  border: 1px solid #2a2a2d;
  overflow: hidden;
}

.nc-meter-fill {
  height: 100%;
  background: #b8ff57;
  width: calc(var(--nc-pct) * 1%);
}

@media (prefers-reduced-motion: reduce) {
  /* the value has to be restated, or it falls back to 0 */
  .nc-meter { animation: none; --nc-pct: 100; }
}

Understand it

How it works and 6 properties

A CSS number counter animates a value from zero up to a target figure with no JavaScript doing the arithmetic. Three pieces make it work. @property registers a custom property as an integer so the browser knows it holds a number it can interpolate, a keyframe animates that property, and counter-reset with content: counter() paints the current value into a pseudo-element. The browser is running real numeric interpolation here, not swapping between two strings.

The counting animation, Replay control, and speed buttons on this page are all pure CSS. Replay is a visually hidden checkbox that swaps the counters onto identical keyframes, so every click plays the count again. The speed buttons (Slow, Med, Fast) are radio buttons whose labels act as the visible buttons. Picking one sets the animation duration and restarts the count by switching to a different set of identical keyframes. Replay and the speed buttons sit in one row above the numbers. The code shown below covers the count and Replay but leaves out the speed buttons. Two further versions follow that code: one that holds the count at zero until a click starts it, and one where a single registered property drives both a number and a progress bar in lockstep.

How it works

@property registers a custom property with a specific type, an integer in this case. CSS can then animate it like a number rather than interpolating between two string values. counter-reset uses the custom property value, and ::after displays it via content:counter(). The whole animation is a CSS keyframe from 0 to the --target value.

A plain custom property is an opaque token to the browser. Write --target: 26 without registering it and the value is a bit of text the parser holds onto until something substitutes it. Nothing in that text says integer, so there is no way to compute a halfway point between 0 and 26, and an animation on it can only flip from one value to the other. @property is what changes that. Declaring syntax: <integer> gives the property a type, and once the browser knows the type it can interpolate.

Getting the animated number onto the screen takes a second mechanism, because a custom property cannot be printed directly. CSS counters fill that gap. counter-reset: num var(--target) creates a counter named num and sets it to whatever the property currently holds, and content: counter(num) on ::after renders it. The counter is re-evaluated every frame the property changes, which is what produces the ticking digits. Adding attr(data-suffix) alongside counter(num) pulls a unit off the element, so the same rule serves a bare count and a percentage.

The animation runs the property up from a keyframe, not down from one. @keyframes count-up { from { --target: 0; } } declares only a start, and the implicit end is whatever the element itself declares. That direction matters more than it looks. The real figure lives in the HTML where it can be edited, translated or served from a template, and deleting the animation rule leaves the correct number on screen instead of a zero.

A timing function applies to every keyframe segment separately, not to the animation as a whole. The counters above use ease-out and behave as expected because there is exactly one segment. Add a stop to hold the number at zero for a beat before it starts, and that ease-out now runs twice: once across the hold, where it does nothing visible, and once across the count. The result is a number that accelerates, stalls, then accelerates again. The meter variant below holds at both ends, so it uses linear and shapes the motion with keyframe positions instead.

Decimals need two counters rather than one fractional property, because counter-reset will not accept a value like 4.8 and drops the whole declaration when it sees one. The rating in the demo runs --target and --decimal as separate registered integers on separate keyframes, then joins them with a literal dot: content: counter(num) "." counter(frac). The same trick covers any fixed separator, which is the only way to get a thousands separator too, since CSS counter styles have no digit grouping.

Swapping between two keyframe sets with identical definitions is how the Replay button works: checking or unchecking the box changes animation-name from count-up to count-up-again, and a new animation name restarts the animation from its first keyframe.

Accessibility notes

Generated content is not real text. A number produced by content: counter() cannot be selected, cannot be copied, and is in the accessibility tree inconsistently across engines. If the figure matters, put it in the DOM as well, either as visually hidden text or as an aria-label on the wrapper, and let the pseudo-element handle the display copy only.

A digit changing sixty times a second is motion, and it belongs behind prefers-reduced-motion. The fix costs one rule. Because the target value is declared on the element rather than in the keyframes, removing the animation under reduce leaves the final number on screen immediately, with nothing else to adjust. Anyone who has asked for less movement gets the information without the spin.

Keep animated counters out of aria-live containers. Mark the pseudo-element's host aria-hidden="true" when a hidden real value is already carrying the number.

The Replay control is a visually hidden checkbox with a visible label styled as a button. It exposes itself as a checkbox, and the space bar toggles it from the keyboard. The focus ring sits on the label.

Mistakes worth avoiding

  • Leaving out the @property block. The number appears at its final value with no motion at all, and nothing errors, so the omission is easy to miss. This is the first thing to check when a counter refuses to animate.
  • Registering the property as a number instead of an integer. counter-reset rejects a fractional value, drops the entire declaration, and the counter never gets created, so counter(num) renders 0 for the whole animation.
  • Putting an easing curve on a keyframe set with more than two stops. The curve restarts at every stop, so a count with a hold at either end speeds up, stalls, then speeds up again. Use linear and shape the motion with the keyframe percentages.
  • Running the count once on page load. It is finished long before anyone scrolls a statistics band into view, leaving a still image of an animation nobody saw. Either loop it with a hold at the target, or trigger it on scroll the way scroll triggered reveal does.
  • Registering with inherits: false and then reading the property from a child element. The child falls back to the initial value and sits motionless while the parent animates, with no warning anywhere.

Ship it

4 features in 4 browsers, 5 questions

Browser support

Feature Chrome Firefox Safari Edge
CSS counters423.112
content on pseudo-elements423.112
CSS animation455.112
custom properties49311016

Can I Use has no entry for the @property at-rule, so it is not in the table. It is the one part of this technique with a recent floor: it shipped in Chromium first and took several more years to reach Firefox and Safari, so it is much younger than every row above. Degradation is gentler than that history suggests. Where the at-rule is missing the property stays unregistered, the browser cannot interpolate it, and var(--target) still substitutes its declared value, so the counter settles on the correct figure and only the movement is lost. Feature detection is awkward because @supports cannot reliably test for an at-rule, which is another reason to keep the real number in the markup.

What you can build with it

  • Statistics bands. The row of figures a marketing page uses for customers served or projects shipped. Counting draws the eye to numbers that would otherwise be read as decoration.
  • Dashboard tiles. A single headline metric per card. The count doubles as a signal that the tile has refreshed.
  • Capacity meters. Storage used or seats filled, where the number and a bar should move together. The second variant below drives both from one property.
  • Score reveals. A quiz result or an audit score that climbs to its total, which reads as a result being calculated rather than printed.
  • Ratings. A one decimal average built from two integer counters joined by a literal dot, the pattern the third figure in the demo uses.

Frequently asked questions

Can CSS count up without JavaScript?
The animation, yes. @property registers a custom property as an integer, a keyframe interpolates it, and a CSS counter renders it. What CSS cannot do is fetch the number. The target has to be written into the markup or the stylesheet by whatever builds the page.
Why is my CSS counter stuck at zero?
Two causes account for most of it. Either the property was never registered with @property, in which case there is no animation and you are seeing the initial value, or the registered syntax is a number rather than an integer, in which case counter-reset throws out the declaration and the counter is never created.
Can a CSS counter show decimals?
Not from one property. Run two registered integers on two keyframes and join them in content with a literal dot, which is what the rating in the demo does. The same approach handles a thousands separator, since CSS counter styles offer no digit grouping of their own.
Can I count down instead of up?
Yes, the counter can count down instead of up. Swap the keyframe to to { --target: 0; } and declare the starting figure on the element. Countdown timer builds a full clock on that principle, with separate counters for minutes and seconds.
Is the animated number selectable or searchable?
No, the animated number is not selectable or searchable. It is generated content and not part of the document text. Search engines and assistive technology should be given the real value in the HTML, with the pseudo-element handling only the visible presentation.