CSS Animated Number Counter

Numbers that count up using @property, CSS counters, and animations.

Published May 19, 2026 Advanced 8 min read

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 on this page is pure CSS. The speed buttons above the demo are wired with a small script on this page, so treat those as page furniture rather than part of the technique. Two further versions follow the code below: 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.

Counting up to a target

Examples
Pure CSS
Rating

HTML

<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>

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;
}

Other ways to build it

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.

Signups this month

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.

Uploading

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; }
}

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 just 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.

CSS properties used

@property
Registers a custom property with a syntax, an initial value and an inheritance flag. Registration is the whole reason the value can animate. inherits: false keeps the value on one element, inherits: true lets children read the animated value.
counter-reset
Creates a named counter and sets it to an integer. Feeding it var(--target) is the join between the animated property and the text on screen.
content
counter(name) renders a counter's current value into ::before or ::after. attr(data-suffix) next to it appends a unit read off the element.
animation
Runs the registered property from its keyframe start value to the value declared on the element. forwards holds the final figure once the run finishes.
animation-play-state
paused freezes an animation at its current time while leaving it applied, so the element shows the keyframe value for that moment. The click variant below parks the count at zero this way.
animation-timing-function
Shapes each keyframe segment on its own. An eased animation with three stops eases three times, which reads as a stall in the middle of a count.

Browser support

FeatureChromeFirefoxSafariEdge
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 genuinely 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.

Accessibility notes

Generated content is not real text. A number produced by content: counter() cannot be selected, cannot be copied, and is exposed to assistive technology 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.

If the counter sits inside a live region, the ticking will be announced repeatedly as it climbs, which turns a single statistic into a stream of noise. Keep animated counters out of aria-live containers, and mark the pseudo-element's host aria-hidden="true" when a hidden real value is already carrying the number.

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 just 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 simply 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.

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.

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. 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. It is generated content, so it cannot be selected or copied, and it is 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.