25 CSS Number Counter Animations02 / 25

Light JSMIT licensed

Scroll-Triggered Count Up Statistics Grid

Stats that count only when the visitor actually reaches them — the single most-requested counter behaviour on the web. This build ships both routes: a pure-CSS animation-timeline: view() path where the number is literally scrubbed by the scroll position, and an IntersectionObserver fallback that fires the same animation once per card in every other browser. Scroll the panel to watch them run.

Published

Live Demo
Try it

The code

<section class="cnc-02" aria-label="Scroll triggered count up statistics grid demo">
  <div class="cnc-02__stage">
    <div class="cnc-02__scroller" tabindex="0" aria-label="Scrollable statistics panel">
      <div class="cnc-02__lead">
        <p class="cnc-02__eyebrow">Scroll this panel ↓</p>
        <h2 class="cnc-02__head">Impact report, 2026</h2>
        <p class="cnc-02__note">Each figure counts up as its card enters the viewport — scroll-driven where supported, IntersectionObserver everywhere else.</p>
      </div>
      <div class="cnc-02__grid">
        <article class="cnc-02__card" style="--cnc-02-i:0">
          <span class="cnc-02__val"><span class="cnc-02__num" style="--cnc-02-to:284" aria-hidden="true"></span><span class="cnc-02__suf">+</span></span>
          <h3 class="cnc-02__key">Enterprise migrations</h3>
          <p class="cnc-02__sub">Zero-downtime cutovers shipped this year</p>
        </article>
        <article class="cnc-02__card" style="--cnc-02-i:1">
          <span class="cnc-02__val"><span class="cnc-02__num" style="--cnc-02-to:63" aria-hidden="true"></span><span class="cnc-02__suf">%</span></span>
          <h3 class="cnc-02__key">Faster onboarding</h3>
          <p class="cnc-02__sub">Median time-to-first-value vs. 2025</p>
        </article>
        <article class="cnc-02__card" style="--cnc-02-i:2">
          <span class="cnc-02__val"><span class="cnc-02__num" style="--cnc-02-to:178" aria-hidden="true"></span><span class="cnc-02__suf">M</span></span>
          <h3 class="cnc-02__key">Events processed daily</h3>
          <p class="cnc-02__sub">Peak sustained ingest across regions</p>
        </article>
        <article class="cnc-02__card" style="--cnc-02-i:3">
          <span class="cnc-02__val"><span class="cnc-02__num" style="--cnc-02-to:47" aria-hidden="true"></span><span class="cnc-02__suf">countries</span></span>
          <h3 class="cnc-02__key">Regional coverage</h3>
          <p class="cnc-02__sub">Data residency zones now available</p>
        </article>
      </div>
      <p class="cnc-02__chip" aria-hidden="true">animation-timeline: view() · animation-range: entry/cover · IntersectionObserver fallback</p>
    </div>
  </div>
</section>
@property --cnc-02-n {
  syntax: '<integer>';
  inherits: false;
  initial-value: 0;
}

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

.cnc-02 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  --cnc-02-bg: oklch(0.96 0.008 250);
  --cnc-02-card: oklch(1 0 0);
  --cnc-02-ink: oklch(0.22 0.02 265);
  --cnc-02-mut: oklch(0.55 0.02 265);
  --cnc-02-acc: oklch(0.55 0.22 275);
  background: var(--cnc-02-bg);
  color: var(--cnc-02-ink);
  font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
}

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

.cnc-02__scroller {
  width: min(100%,860px);
  height: min(78vh,620px);
  overflow: auto;
  overscroll-behavior: contain;
  scroll-behavior: smooth;
  border-radius: 22px;
  background: var(--cnc-02-card);
  box-shadow: 0 1px 2px oklch(0 0 0/.06),0 24px 60px oklch(0.3 0.05 275/.14);
  padding: clamp(20px,4vw,38px);
}

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

.cnc-02__lead {
  max-width: 46ch;
  padding-bottom: clamp(30px,10vh,90px);
  border-bottom: 1px solid oklch(0 0 0/.07);
}

.cnc-02__eyebrow {
  font-size: 11.5px;
  font-weight: 700;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--cnc-02-acc);
}

.cnc-02__head {
  margin-top: 10px;
  font-size: clamp(24px,3.4vw,34px);
  font-weight: 680;
  letter-spacing: -.03em;
}

.cnc-02__note {
  margin-top: 10px;
  font-size: 14px;
  line-height: 1.55;
  color: var(--cnc-02-mut);
  text-wrap: pretty;
}

.cnc-02__grid {
  display: grid;
  grid-template-columns: repeat(auto-fit,minmax(220px,1fr));
  gap: 14px;
  padding: clamp(24px,6vh,60px) 0;
}

.cnc-02__card {
  padding: 22px;
  border-radius: 16px;
  background: color-mix(in oklch,var(--cnc-02-acc) 5%,var(--cnc-02-card));
  border: 1px solid color-mix(in oklch,var(--cnc-02-acc) 16%,transparent);
  transition: transform .3s cubic-bezier(.16,1,.3,1),box-shadow .3s;
}

.cnc-02__card:hover {
  transform: translateY(-3px);
  box-shadow: 0 12px 30px oklch(0.3 0.05 275/.16);
}

.cnc-02__val {
  display: flex;
  align-items: baseline;
  gap: 2px;
  font-size: clamp(34px,5vw,48px);
  font-weight: 720;
  letter-spacing: -.045em;
  line-height: 1;
  color: var(--cnc-02-acc);
  font-variant-numeric: tabular-nums;
  font-feature-settings: 'tnum' 1;
}

.cnc-02__num {
  counter-reset: cnc-02-n var(--cnc-02-n);
}

.cnc-02__num::after {
  content: counter(cnc-02-n);
}

.cnc-02__suf {
  font-size: .42em;
  font-weight: 640;
  letter-spacing: 0;
}

.cnc-02__key {
  margin-top: 14px;
  font-size: 15px;
  font-weight: 640;
  letter-spacing: -.01em;
}

.cnc-02__sub {
  margin-top: 5px;
  font-size: 13px;
  line-height: 1.5;
  color: var(--cnc-02-mut);
}

@keyframes cnc-02-count {
  from {
    --cnc-02-n: 0;
  }

  to {
    --cnc-02-n: var(--cnc-02-to);
  }
}

@keyframes cnc-02-rise {
  from {
    opacity: 0;
    transform: translateY(18px);
  }

  to {
    opacity: 1;
    transform: none;
  }
}

.cnc-02__card[data-run] .cnc-02__num {
  animation: cnc-02-count 1.9s cubic-bezier(.16,1,.3,1) both;
}

.cnc-02__card[data-run] {
  animation: cnc-02-rise .7s cubic-bezier(.16,1,.3,1) both;
}

@supports (animation-timeline: view()) {
  .cnc-02__num {
    animation: cnc-02-count linear both;
    animation-timeline: view();
    animation-range: entry 18% cover 44%;
  }

  .cnc-02__card {
    animation: cnc-02-rise linear both;
    animation-timeline: view();
    animation-range: entry 5% entry 60%;
  }
}

.cnc-02__chip {
  margin: 6px 0 4px;
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11.5px;
  color: var(--cnc-02-mut);
  padding: 8px 14px;
  border: 1px dashed oklch(0 0 0/.14);
  border-radius: 99px;
  text-align: center;
}

@media (prefers-reduced-motion: reduce) {
  .cnc-02__num,
    .cnc-02__card {
    animation-duration: 1ms !important;
    animation-timeline: auto !important;
  }
}
(() => {
  const supportsView = CSS.supports('animation-timeline', 'view()');
  document.querySelectorAll('.cnc-02').forEach((root) => {
    if (root.dataset.wired) return;
    root.dataset.wired = '1';
    const cards = [...root.querySelectorAll('.cnc-02__card')];
    if (supportsView) return;                       // CSS already handles it
    const io = new IntersectionObserver((entries) => {
      entries.forEach((e) => {
        if (!e.isIntersecting) return;
        e.target.style.animationDelay = (e.target.style.getPropertyValue('--cnc-02-i') || 0) * 90 + 'ms';
        e.target.dataset.run = '1';
        io.unobserve(e.target);
      });
    }, { root: root.querySelector('.cnc-02__scroller'), threshold: 0.35, rootMargin: '0px 0px -8% 0px' });
    cards.forEach((c) => io.observe(c));
  });
})();
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: Scroll-Triggered Count Up Statistics Grid
Source: https://codefronts.com/motion/css-number-counter-animations/scroll-triggered-count-up-statistics-grid/

Stats that count only when the visitor actually reaches them — the single most-requested counter behaviour on the web. This build ships both routes: a pure-CSS animation-timeline: view() path where the number is literally scrubbed by the scroll position, and an IntersectionObserver fallback that fires the same animation once per card in every other browser. Scroll the panel to watch them run.
## HTML
```html
<section class="cnc-02" aria-label="Scroll triggered count up statistics grid demo">
  <div class="cnc-02__stage">
    <div class="cnc-02__scroller" tabindex="0" aria-label="Scrollable statistics panel">
      <div class="cnc-02__lead">
        <p class="cnc-02__eyebrow">Scroll this panel ↓</p>
        <h2 class="cnc-02__head">Impact report, 2026</h2>
        <p class="cnc-02__note">Each figure counts up as its card enters the viewport — scroll-driven where supported, IntersectionObserver everywhere else.</p>
      </div>
      <div class="cnc-02__grid">
        <article class="cnc-02__card" style="--cnc-02-i:0">
          <span class="cnc-02__val"><span class="cnc-02__num" style="--cnc-02-to:284" aria-hidden="true"></span><span class="cnc-02__suf">+</span></span>
          <h3 class="cnc-02__key">Enterprise migrations</h3>
          <p class="cnc-02__sub">Zero-downtime cutovers shipped this year</p>
        </article>
        <article class="cnc-02__card" style="--cnc-02-i:1">
          <span class="cnc-02__val"><span class="cnc-02__num" style="--cnc-02-to:63" aria-hidden="true"></span><span class="cnc-02__suf">%</span></span>
          <h3 class="cnc-02__key">Faster onboarding</h3>
          <p class="cnc-02__sub">Median time-to-first-value vs. 2025</p>
        </article>
        <article class="cnc-02__card" style="--cnc-02-i:2">
          <span class="cnc-02__val"><span class="cnc-02__num" style="--cnc-02-to:178" aria-hidden="true"></span><span class="cnc-02__suf">M</span></span>
          <h3 class="cnc-02__key">Events processed daily</h3>
          <p class="cnc-02__sub">Peak sustained ingest across regions</p>
        </article>
        <article class="cnc-02__card" style="--cnc-02-i:3">
          <span class="cnc-02__val"><span class="cnc-02__num" style="--cnc-02-to:47" aria-hidden="true"></span><span class="cnc-02__suf">countries</span></span>
          <h3 class="cnc-02__key">Regional coverage</h3>
          <p class="cnc-02__sub">Data residency zones now available</p>
        </article>
      </div>
      <p class="cnc-02__chip" aria-hidden="true">animation-timeline: view() · animation-range: entry/cover · IntersectionObserver fallback</p>
    </div>
  </div>
</section>
```
## CSS
```css
@property --cnc-02-n {
  syntax: '<integer>';
  inherits: false;
  initial-value: 0;
}

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

.cnc-02 {
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: block;
  --cnc-02-bg: oklch(0.96 0.008 250);
  --cnc-02-card: oklch(1 0 0);
  --cnc-02-ink: oklch(0.22 0.02 265);
  --cnc-02-mut: oklch(0.55 0.02 265);
  --cnc-02-acc: oklch(0.55 0.22 275);
  background: var(--cnc-02-bg);
  color: var(--cnc-02-ink);
  font-family: 'Segoe UI',system-ui,-apple-system,sans-serif;
}

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

.cnc-02__scroller {
  width: min(100%,860px);
  height: min(78vh,620px);
  overflow: auto;
  overscroll-behavior: contain;
  scroll-behavior: smooth;
  border-radius: 22px;
  background: var(--cnc-02-card);
  box-shadow: 0 1px 2px oklch(0 0 0/.06),0 24px 60px oklch(0.3 0.05 275/.14);
  padding: clamp(20px,4vw,38px);
}

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

.cnc-02__lead {
  max-width: 46ch;
  padding-bottom: clamp(30px,10vh,90px);
  border-bottom: 1px solid oklch(0 0 0/.07);
}

.cnc-02__eyebrow {
  font-size: 11.5px;
  font-weight: 700;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--cnc-02-acc);
}

.cnc-02__head {
  margin-top: 10px;
  font-size: clamp(24px,3.4vw,34px);
  font-weight: 680;
  letter-spacing: -.03em;
}

.cnc-02__note {
  margin-top: 10px;
  font-size: 14px;
  line-height: 1.55;
  color: var(--cnc-02-mut);
  text-wrap: pretty;
}

.cnc-02__grid {
  display: grid;
  grid-template-columns: repeat(auto-fit,minmax(220px,1fr));
  gap: 14px;
  padding: clamp(24px,6vh,60px) 0;
}

.cnc-02__card {
  padding: 22px;
  border-radius: 16px;
  background: color-mix(in oklch,var(--cnc-02-acc) 5%,var(--cnc-02-card));
  border: 1px solid color-mix(in oklch,var(--cnc-02-acc) 16%,transparent);
  transition: transform .3s cubic-bezier(.16,1,.3,1),box-shadow .3s;
}

.cnc-02__card:hover {
  transform: translateY(-3px);
  box-shadow: 0 12px 30px oklch(0.3 0.05 275/.16);
}

.cnc-02__val {
  display: flex;
  align-items: baseline;
  gap: 2px;
  font-size: clamp(34px,5vw,48px);
  font-weight: 720;
  letter-spacing: -.045em;
  line-height: 1;
  color: var(--cnc-02-acc);
  font-variant-numeric: tabular-nums;
  font-feature-settings: 'tnum' 1;
}

.cnc-02__num {
  counter-reset: cnc-02-n var(--cnc-02-n);
}

.cnc-02__num::after {
  content: counter(cnc-02-n);
}

.cnc-02__suf {
  font-size: .42em;
  font-weight: 640;
  letter-spacing: 0;
}

.cnc-02__key {
  margin-top: 14px;
  font-size: 15px;
  font-weight: 640;
  letter-spacing: -.01em;
}

.cnc-02__sub {
  margin-top: 5px;
  font-size: 13px;
  line-height: 1.5;
  color: var(--cnc-02-mut);
}

@keyframes cnc-02-count {
  from {
    --cnc-02-n: 0;
  }

  to {
    --cnc-02-n: var(--cnc-02-to);
  }
}

@keyframes cnc-02-rise {
  from {
    opacity: 0;
    transform: translateY(18px);
  }

  to {
    opacity: 1;
    transform: none;
  }
}

.cnc-02__card[data-run] .cnc-02__num {
  animation: cnc-02-count 1.9s cubic-bezier(.16,1,.3,1) both;
}

.cnc-02__card[data-run] {
  animation: cnc-02-rise .7s cubic-bezier(.16,1,.3,1) both;
}

@supports (animation-timeline: view()) {
  .cnc-02__num {
    animation: cnc-02-count linear both;
    animation-timeline: view();
    animation-range: entry 18% cover 44%;
  }

  .cnc-02__card {
    animation: cnc-02-rise linear both;
    animation-timeline: view();
    animation-range: entry 5% entry 60%;
  }
}

.cnc-02__chip {
  margin: 6px 0 4px;
  font-family: ui-monospace,Menlo,Consolas,monospace;
  font-size: 11.5px;
  color: var(--cnc-02-mut);
  padding: 8px 14px;
  border: 1px dashed oklch(0 0 0/.14);
  border-radius: 99px;
  text-align: center;
}

@media (prefers-reduced-motion: reduce) {
  .cnc-02__num,
    .cnc-02__card {
    animation-duration: 1ms !important;
    animation-timeline: auto !important;
  }
}
```

## JavaScript
```js
(() => {
  const supportsView = CSS.supports('animation-timeline', 'view()');
  document.querySelectorAll('.cnc-02').forEach((root) => {
    if (root.dataset.wired) return;
    root.dataset.wired = '1';
    const cards = [...root.querySelectorAll('.cnc-02__card')];
    if (supportsView) return;                       // CSS already handles it
    const io = new IntersectionObserver((entries) => {
      entries.forEach((e) => {
        if (!e.isIntersecting) return;
        e.target.style.animationDelay = (e.target.style.getPropertyValue('--cnc-02-i') || 0) * 90 + 'ms';
        e.target.dataset.run = '1';
        io.unobserve(e.target);
      });
    }, { root: root.querySelector('.cnc-02__scroller'), threshold: 0.35, rootMargin: '0px 0px -8% 0px' });
    cards.forEach((c) => io.observe(c));
  });
})();
```

How this works

Modern CSS can drive an animation from scroll position directly, no observer required:

@supports (animation-timeline: view()) {
  .num {
    animation: count linear both;
    animation-timeline: view();          /* this element's visibility */
    animation-range: entry 20% cover 45%; /* start & finish points */
  }
}

The number is now a function of scroll offset — scroll back up and it counts down, which feels wonderfully physical on a stats band. animation-range is where the craft lives: entry 20% starts once a fifth of the card has entered the scrollport, cover 45% finishes it before it reaches the middle, so the number has settled by the time it is comfortably readable.

const io = new IntersectionObserver((es) => es.forEach((e) => {
  if (e.isIntersecting) { e.target.dataset.run = '1'; io.unobserve(e.target); }
}), { threshold: 0.35, rootMargin: '0px 0px -12% 0px' });

The fallback runs only when CSS.supports('animation-timeline','view()') is false, and it unobserves after the first hit so the number never replays and re-announces itself. Both paths animate the same registered --cnc-02-n property, so there is one source of truth for the target values.

Make it yours

  • Count down instead of up on scroll-back: that is the default with view() — remove both from the JS path if you want the same behaviour there via a scrub loop.
  • Trigger earlier on mobile: widen the range to entry 0% cover 30%, since short viewports show cards for less time.
  • Make the whole card animate: attach a second animation (opacity + translateY) on the same timeline for a synchronised reveal.
  • Replay every visit: drop the io.unobserve() call and toggle data-run off when the card leaves — useful in long marketing pages where visitors scroll back.

Gotchas — read before shipping

  • A scroll-driven animation needs a scrollable ancestor. Inside a fixed-height playground the timeline resolves against that scroller — this demo makes its own overflow:auto panel so it works anywhere.
  • Never re-fire the observer on every intersection; announcements stack and the number pops repeatedly. Unobserve after the first run.
  • threshold:1 fails for cards taller than the viewport — they can never be 100% visible. Use 0.3–0.5 plus a negative rootMargin.
  • IntersectionObserver callbacks fire once on page load for elements already in view; that is correct behaviour, not a bug — guard with dataset flags if you need a delay.
  • Don't put the aria-live region on the animated digits. Announce the final value once when the count settles, or the screen reader reads a slot machine.

Browser support

ChromeSafariFirefoxEdge
115+17+128+115+

animation-timeline: view() is Chrome/Edge 115+ and Firefox 144+ behind broad rollout; Safari ships scroll-driven animations from 26. Everything else takes the IntersectionObserver path (universal since 2019), so the behaviour is identical everywhere.

Techniques used in this demo

Search CodeFronts

Loading…