25 CSS Number Counter Animations17 / 25

Light JSMIT licensed

Blog Reading Time & Scroll Progress Indicator

A reading-progress header with a live percentage — and not one line of JavaScript. animation-timeline: scroll() ties both the bar and a counting percentage directly to the scroll position of the article, so the number is scrubbed by the reader's own gesture. Minutes remaining tick down the same way.

Published Updated

Live Demo
Try it

The code

<section class="cnc-17" aria-label="Blog reading time and scroll progress indicator demo">
  <div class="cnc-17__stage">
    <div class="cnc-17__reader">
      <header class="cnc-17__bar">
        <div class="cnc-17__meta">
          <p class="cnc-17__kicker">Engineering · 8 min read</p>
          <h3 class="cnc-17__title">Why your counters should live in CSS</h3>
        </div>
        <div class="cnc-17__readout">
          <span class="cnc-17__pct" aria-hidden="true"></span>
          <span class="cnc-17__mins" aria-hidden="true"></span>
        </div>
        <span class="cnc-17__progress" aria-hidden="true"><b></b></span>
      </header>
      <article class="cnc-17__article" tabindex="0" aria-label="Article body, scroll to see progress">
        <p class="cnc-17__lead">Scroll this column. The bar, the percentage and the minutes remaining are all driven by the same scroll timeline — no listener, no JavaScript, no jank.</p>
        <p>Counters in CSS start with a registered custom property. Registration gives the property a type, and a typed property can be interpolated by the animation engine rather than swapped at the halfway point.</p>
        <p>Once the value animates, a counter can read it and <code>content</code> can print it. The number on screen is therefore a genuine interpolation, not a sequence of strings written by a script on every frame.</p>
        <p>Scroll-driven animations extend the same idea to gesture. The timeline is no longer wall-clock time but scroll offset, so the reader controls the value directly, and the browser can run the whole thing off the main thread.</p>
        <p>That matters most on touch devices. A JavaScript scroll handler runs after the compositor has already moved the page, so the indicator trails the content by a frame or two — visible, and impossible to fix with throttling.</p>
        <p>The practical rule: if a value is a pure function of scroll position, express it as a scroll-driven animation. If it depends on data, use JavaScript to set one custom property and let CSS derive everything else from it.</p>
        <p>Everything else — easing, staggering, reduced-motion behaviour, theming — stays in the stylesheet where designers can reach it without touching application code.</p>
        <p class="cnc-17__end">— end of article —</p>
      </article>
    </div>
    <p class="cnc-17__chip" aria-hidden="true">rAF scroll listener · one --p custom property · CSS counter() readouts · cross-browser</p>
  </div>
</section>
@property --cnc-17-n {
  syntax: '<integer>';
  inherits: true;
  initial-value: 0;
}

@property --cnc-17-m {
  syntax: '<integer>';
  inherits: true;
  initial-value: 8;
}

.cnc-17,
.cnc-17 *,
.cnc-17 *::before,
.cnc-17 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.cnc-17 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  --cnc-17-bg: oklch(0.96 0.006 80);
  --cnc-17-card: oklch(1 0 0);
  --cnc-17-ink: oklch(0.22 0.015 265);
  --cnc-17-mut: oklch(0.55 0.015 265);
  --cnc-17-acc: oklch(0.58 0.19 25);
  background: var(--cnc-17-bg);
  color: var(--cnc-17-ink);
  font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
}

.cnc-17__stage {
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-items: center;
  align-content: center;
  gap: 16px;
  padding: clamp(16px,4vw,44px);
}

.cnc-17__reader {
  width: min(100%,660px);
  border-radius: 20px;
  overflow: hidden;
  background: var(--cnc-17-card);
  box-shadow: 0 1px 2px oklch(0 0 0/.05),0 24px 56px oklch(0.4 0.02 265/.14);
}

.cnc-17__bar {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  padding: 16px 20px 18px;
  border-bottom: 1px solid oklch(0 0 0/.07);
}

.cnc-17__kicker {
  font-size: 11px;
  font-weight: 700;
  letter-spacing: .13em;
  text-transform: uppercase;
  color: var(--cnc-17-acc);
}

.cnc-17__title {
  margin-top: 5px;
  font-size: 16.5px;
  font-weight: 660;
  letter-spacing: -.02em;
}

.cnc-17__readout {
  display: grid;
  justify-items: end;
  gap: 2px;
  flex: none;
}

.cnc-17__pct {
  font-size: 22px;
  font-weight: 750;
  letter-spacing: -.04em;
  font-variant-numeric: tabular-nums;
  font-feature-settings: 'tnum' 1;
  counter-reset: cnc-17-n var(--cnc-17-n);
}

.cnc-17__pct::after {
  content: counter(cnc-17-n) "%";
}

.cnc-17__mins {
  font-size: 11.5px;
  color: var(--cnc-17-mut);
  counter-reset: cnc-17-m var(--cnc-17-m);
}

.cnc-17__mins::after {
  content: counter(cnc-17-m) " min left";
}

.cnc-17__progress {
  position: absolute;
  left: 0;
  right: 0;
  bottom: -1px;
  height: 3px;
  background: oklch(0 0 0/.07);
}

.cnc-17__progress b {
  display: block;
  height: 100%;
  width: 100%;
  transform-origin: left center;
  transform: scaleX(var(--cnc-17-p,0));
  background: linear-gradient(90deg,var(--cnc-17-acc),oklch(0.72 0.17 55));
  transition: transform .1s linear;
}

.cnc-17__article {
  height: min(52vh,420px);
  overflow: auto;
  overscroll-behavior: contain;
  padding: 22px 24px 30px;
  display: grid;
  gap: 14px;
  font-size: 14.5px;
  line-height: 1.65;
  color: color-mix(in oklch,var(--cnc-17-ink) 88%,transparent);
  text-wrap: pretty;
}

.cnc-17__article:focus-visible {
  outline: 3px solid var(--cnc-17-acc);
  outline-offset: -3px;
}

.cnc-17__lead {
  font-size: 16px;
  font-weight: 600;
  color: var(--cnc-17-ink);
}

.cnc-17__article code {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: .88em;
  padding: 1px 5px;
  border-radius: 5px;
  background: oklch(0 0 0/.06);
}

.cnc-17__end {
  text-align: center;
  font-size: 12px;
  color: var(--cnc-17-mut);
  padding-top: 8px;
}

.cnc-17__chip {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11.5px;
  color: var(--cnc-17-mut);
  padding: 7px 14px;
  border: 1px solid oklch(0 0 0/.12);
  border-radius: 99px;
  text-align: center;
  max-width: min(100%,560px);
}

@media (max-width: 520px) {
  .cnc-17__bar {
    flex-wrap: wrap;
  }
}

@media (prefers-reduced-motion: reduce) {
  .cnc-17__progress b {
    transition: none;
  }
}
(() => {
  const reader = document.querySelector('.cnc-17');
  const article = reader && reader.querySelector('.cnc-17__article');
  if (!article || article.dataset.cncWired) return;
  article.dataset.cncWired = '1';
  const TOTAL_MIN = 8;
  let raf = 0;
  const update = () => {
    raf = 0;
    const max = article.scrollHeight - article.clientHeight;
    const p = max > 0 ? Math.min(1, Math.max(0, article.scrollTop / max)) : 0;
    reader.style.setProperty('--cnc-17-p', p.toFixed(4));
    reader.style.setProperty('--cnc-17-n', Math.round(p * 100));
    reader.style.setProperty('--cnc-17-m', Math.max(0, Math.round(TOTAL_MIN * (1 - p))));
  };
  article.addEventListener('scroll', () => {
    if (!raf) raf = requestAnimationFrame(update);
  }, { passive: true });
  update();
})();
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS Number Counter Animation from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: Blog Reading Time & Scroll Progress Indicator
Source: https://codefronts.com/motion/css-number-counter-animations/reading-progress-counter/

A reading-progress header with a live percentage — and not one line of JavaScript. animation-timeline: scroll() ties both the bar and a counting percentage directly to the scroll position of the article, so the number is scrubbed by the reader's own gesture. Minutes remaining tick down the same way.
## HTML
```html
<section class="cnc-17" aria-label="Blog reading time and scroll progress indicator demo">
  <div class="cnc-17__stage">
    <div class="cnc-17__reader">
      <header class="cnc-17__bar">
        <div class="cnc-17__meta">
          <p class="cnc-17__kicker">Engineering · 8 min read</p>
          <h3 class="cnc-17__title">Why your counters should live in CSS</h3>
        </div>
        <div class="cnc-17__readout">
          <span class="cnc-17__pct" aria-hidden="true"></span>
          <span class="cnc-17__mins" aria-hidden="true"></span>
        </div>
        <span class="cnc-17__progress" aria-hidden="true"><b></b></span>
      </header>
      <article class="cnc-17__article" tabindex="0" aria-label="Article body, scroll to see progress">
        <p class="cnc-17__lead">Scroll this column. The bar, the percentage and the minutes remaining are all driven by the same scroll timeline — no listener, no JavaScript, no jank.</p>
        <p>Counters in CSS start with a registered custom property. Registration gives the property a type, and a typed property can be interpolated by the animation engine rather than swapped at the halfway point.</p>
        <p>Once the value animates, a counter can read it and <code>content</code> can print it. The number on screen is therefore a genuine interpolation, not a sequence of strings written by a script on every frame.</p>
        <p>Scroll-driven animations extend the same idea to gesture. The timeline is no longer wall-clock time but scroll offset, so the reader controls the value directly, and the browser can run the whole thing off the main thread.</p>
        <p>That matters most on touch devices. A JavaScript scroll handler runs after the compositor has already moved the page, so the indicator trails the content by a frame or two — visible, and impossible to fix with throttling.</p>
        <p>The practical rule: if a value is a pure function of scroll position, express it as a scroll-driven animation. If it depends on data, use JavaScript to set one custom property and let CSS derive everything else from it.</p>
        <p>Everything else — easing, staggering, reduced-motion behaviour, theming — stays in the stylesheet where designers can reach it without touching application code.</p>
        <p class="cnc-17__end">— end of article —</p>
      </article>
    </div>
    <p class="cnc-17__chip" aria-hidden="true">rAF scroll listener · one --p custom property · CSS counter() readouts · cross-browser</p>
  </div>
</section>
```
## CSS
```css
@property --cnc-17-n {
  syntax: '<integer>';
  inherits: true;
  initial-value: 0;
}

@property --cnc-17-m {
  syntax: '<integer>';
  inherits: true;
  initial-value: 8;
}

.cnc-17,
.cnc-17 *,
.cnc-17 *::before,
.cnc-17 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.cnc-17 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  --cnc-17-bg: oklch(0.96 0.006 80);
  --cnc-17-card: oklch(1 0 0);
  --cnc-17-ink: oklch(0.22 0.015 265);
  --cnc-17-mut: oklch(0.55 0.015 265);
  --cnc-17-acc: oklch(0.58 0.19 25);
  background: var(--cnc-17-bg);
  color: var(--cnc-17-ink);
  font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
}

.cnc-17__stage {
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-items: center;
  align-content: center;
  gap: 16px;
  padding: clamp(16px,4vw,44px);
}

.cnc-17__reader {
  width: min(100%,660px);
  border-radius: 20px;
  overflow: hidden;
  background: var(--cnc-17-card);
  box-shadow: 0 1px 2px oklch(0 0 0/.05),0 24px 56px oklch(0.4 0.02 265/.14);
}

.cnc-17__bar {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  padding: 16px 20px 18px;
  border-bottom: 1px solid oklch(0 0 0/.07);
}

.cnc-17__kicker {
  font-size: 11px;
  font-weight: 700;
  letter-spacing: .13em;
  text-transform: uppercase;
  color: var(--cnc-17-acc);
}

.cnc-17__title {
  margin-top: 5px;
  font-size: 16.5px;
  font-weight: 660;
  letter-spacing: -.02em;
}

.cnc-17__readout {
  display: grid;
  justify-items: end;
  gap: 2px;
  flex: none;
}

.cnc-17__pct {
  font-size: 22px;
  font-weight: 750;
  letter-spacing: -.04em;
  font-variant-numeric: tabular-nums;
  font-feature-settings: 'tnum' 1;
  counter-reset: cnc-17-n var(--cnc-17-n);
}

.cnc-17__pct::after {
  content: counter(cnc-17-n) "%";
}

.cnc-17__mins {
  font-size: 11.5px;
  color: var(--cnc-17-mut);
  counter-reset: cnc-17-m var(--cnc-17-m);
}

.cnc-17__mins::after {
  content: counter(cnc-17-m) " min left";
}

.cnc-17__progress {
  position: absolute;
  left: 0;
  right: 0;
  bottom: -1px;
  height: 3px;
  background: oklch(0 0 0/.07);
}

.cnc-17__progress b {
  display: block;
  height: 100%;
  width: 100%;
  transform-origin: left center;
  transform: scaleX(var(--cnc-17-p,0));
  background: linear-gradient(90deg,var(--cnc-17-acc),oklch(0.72 0.17 55));
  transition: transform .1s linear;
}

.cnc-17__article {
  height: min(52vh,420px);
  overflow: auto;
  overscroll-behavior: contain;
  padding: 22px 24px 30px;
  display: grid;
  gap: 14px;
  font-size: 14.5px;
  line-height: 1.65;
  color: color-mix(in oklch,var(--cnc-17-ink) 88%,transparent);
  text-wrap: pretty;
}

.cnc-17__article:focus-visible {
  outline: 3px solid var(--cnc-17-acc);
  outline-offset: -3px;
}

.cnc-17__lead {
  font-size: 16px;
  font-weight: 600;
  color: var(--cnc-17-ink);
}

.cnc-17__article code {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: .88em;
  padding: 1px 5px;
  border-radius: 5px;
  background: oklch(0 0 0/.06);
}

.cnc-17__end {
  text-align: center;
  font-size: 12px;
  color: var(--cnc-17-mut);
  padding-top: 8px;
}

.cnc-17__chip {
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11.5px;
  color: var(--cnc-17-mut);
  padding: 7px 14px;
  border: 1px solid oklch(0 0 0/.12);
  border-radius: 99px;
  text-align: center;
  max-width: min(100%,560px);
}

@media (max-width: 520px) {
  .cnc-17__bar {
    flex-wrap: wrap;
  }
}

@media (prefers-reduced-motion: reduce) {
  .cnc-17__progress b {
    transition: none;
  }
}
```

## JavaScript
```js
(() => {
  const reader = document.querySelector('.cnc-17');
  const article = reader && reader.querySelector('.cnc-17__article');
  if (!article || article.dataset.cncWired) return;
  article.dataset.cncWired = '1';
  const TOTAL_MIN = 8;
  let raf = 0;
  const update = () => {
    raf = 0;
    const max = article.scrollHeight - article.clientHeight;
    const p = max > 0 ? Math.min(1, Math.max(0, article.scrollTop / max)) : 0;
    reader.style.setProperty('--cnc-17-p', p.toFixed(4));
    reader.style.setProperty('--cnc-17-n', Math.round(p * 100));
    reader.style.setProperty('--cnc-17-m', Math.max(0, Math.round(TOTAL_MIN * (1 - p))));
  };
  article.addEventListener('scroll', () => {
    if (!raf) raf = requestAnimationFrame(update);
  }, { passive: true });
  update();
})();
```

How this works

A ten-line rAF scroll listener writes a single custom property; CSS derives every visible readout from it. The bar's scaleX, the percentage counter, and the minutes-remaining counter all read the same --cnc-17-p value — the demo can never desync internally because there's only one source of truth:

const article = document.querySelector('.cnc-17__article');
const reader  = document.querySelector('.cnc-17');
let raf = 0;
const update = () => {
  raf = 0;
  const max = article.scrollHeight - article.clientHeight;
  const p = max > 0 ? Math.min(1, Math.max(0, article.scrollTop / max)) : 0;
  reader.style.setProperty('--cnc-17-p', p.toFixed(4));
  reader.style.setProperty('--cnc-17-n', Math.round(p * 100));
  reader.style.setProperty('--cnc-17-m', Math.max(0, Math.round(8 * (1 - p))));
};
article.addEventListener('scroll', () => {
  if (!raf) raf = requestAnimationFrame(update);
}, { passive: true });

The rAF throttle guarantees at-most-one update per frame — you can't overwork the main thread even if scroll events fire on every pixel. { passive: true } tells the browser it can start scrolling before the listener runs, so touch inertia never jitters.

.cnc-17__progress b { transform: scaleX(var(--cnc-17-p, 0)) }
.cnc-17__pct { counter-reset: cnc-17-n var(--cnc-17-n) }
.cnc-17__pct::after { content: counter(cnc-17-n) "%" }
.cnc-17__mins { counter-reset: cnc-17-m var(--cnc-17-m) }
.cnc-17__mins::after { content: counter(cnc-17-m) " min left" }

The @property registrations at the top give --cnc-17-n and --cnc-17-m integer type + inherits: true so the values propagate from .cnc-17 down to the counter elements inside .cnc-17__bar. Result: portable across every current browser (baseline for @property is 2023), scoped to the article scroller (not window), and honest — no CSS-only fallback that lies about progress on non-Chromium browsers.

Make it yours

  • Page-level version: drop the named timeline and use animation-timeline: scroll(root block) to track the document instead of a container.
  • Circular indicator: apply the same timeline to a conic-gradient ring (demo 10) for a floating corner progress dial.
  • Section-aware: give each <h2> a view() timeline to highlight the current chapter in a table of contents — same mechanism, different range.
  • Reading time: compute words ÷ 220 server-side and set it as the starting value of the countdown keyframe.

Gotchas — read before shipping

  • scroll-timeline must name a scroll container that actually scrolls — an ancestor with overflow:auto and a constrained height. Attaching to a non-scrolling element silently does nothing.
  • The timeline name is scoped: the animated element must be a descendant of the timeline-defining element, or you need timeline-scope.
  • animation-timeline requires animation-fill-mode both (or a from/to pair) to hold its value at the extremes.
  • JS scroll handlers and scroll-driven animations behave differently on iOS momentum scrolling; do not mix both for the same indicator.
  • A progress bar that is purely decorative should be aria-hidden; if it is meaningful, expose role="progressbar" and update aria-valuenow — which does need a little JS.

Browser support

ChromeSafariFirefoxEdge
115+26+144+115+

Scroll-driven animations: Chrome/Edge 115+, Safari 26+, Firefox 144+ (earlier behind a flag). The @supports block degrades to a static bar and hidden percentages, so nothing looks broken.

Search CodeFronts

Loading…