webdevpuneet.com

CountUp.js Stat Cards — Scroll-Triggered Counter Snippet

CountUp.js Stat Cards · Cards · Plain HTML, CSS & JS · Live preview

CategoryCards

Copy & paste

Source Code

Requires
<div class="csc-stage">
  <div class="csc-head">
    <span class="csc-tag">countup.js · IntersectionObserver trigger</span>
    <h2>By The Numbers</h2>
    <p>Scroll down — each stat counts up once, the moment its card enters the viewport.</p>
  </div>
  <div class="csc-grid">
    <div class="csc-card"><span class="csc-val" id="statUsers">0</span><span class="csc-lbl">Active Users</span></div>
    <div class="csc-card"><span class="csc-val" id="statUptime">0</span><span class="csc-lbl">Uptime</span></div>
    <div class="csc-card"><span class="csc-val" id="statLatency">0</span><span class="csc-lbl">Avg Latency</span></div>
    <div class="csc-card"><span class="csc-val" id="statRating">0</span><span class="csc-lbl">App Store Rating</span></div>
  </div>
</div>

Extend with AI

Take This Snippet Further With AI

The AI prompt for this one is for Patreon supporters ($3 a month, 7 days free), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.

· Become a supporter

What's included

Features

Shared IntersectionObserver
One observer instance watches all four cards instead of one per card.
Fire-once guarantee
A started map plus observer.unobserve() together prevent double-counting on re-scroll.
Per-stat formatting options
Each card passes its own separator, decimalPlaces, prefix, and suffix to CountUp.
Tuned visibility threshold
0.4 avoids triggering on a barely-visible edge while still firing before the card is fully scrolled past.
Error-checked instantiation
counter.error is checked before calling start(), guarding against a misconfigured target.
Config-driven cards
Adding a stat is a one-line addition to a data array, not new animation code.
No animation library conflicts
CountUp's own internal rAF loop drives the count, independent of the observer.
Responsive 4-to-2 column grid
The stat grid collapses gracefully on narrow viewports.

About this UI Snippet

CountUp.js Stat Cards — Scroll Triggering and Per-Card Formatting, Explained

Screenshot of the CountUp.js Stat Cards snippet rendered live

CountUp.js itself has no idea about scrolling — it just animates a number from a start value to an end value over a duration. Making that count-up feel like a reveal, rather than something that already finished by the time the user scrolls down to see it, is entirely the job of wiring it to visibility, which is what this snippet does with a single shared IntersectionObserver.

One observer, four targets, fire-once bookkeeping

js var observer = new IntersectionObserver(function (entries) { entries.forEach(function (entry) { if (!entry.isIntersecting) return; var id = entry.target.id; if (started[id]) return; started[id] = true; ... observer.unobserve(entry.target); }); }, { threshold: 0.4 });

A single observer watches all four stat cards rather than creating one observer per card — cheaper, and it's the idiomatic pattern since IntersectionObserver is designed to batch multiple targets under one callback. The started object is the guard against double-counting: without it, a card that crosses the 40% visibility threshold, briefly dips below it during a fast scroll, and crosses back would restart its animation from zero and count up a second time, which looks broken. observer.unobserve(entry.target) is a second, belt-and-suspenders safeguard that removes the element from observation entirely once it has counted, so the callback won't even fire again for it.

Why threshold: 0.4 and not 0 or 1

threshold: 0 fires the instant a single pixel is visible, which can trigger the count-up before the card is meaningfully "in view" (e.g. one row peeking at the very bottom edge). threshold: 1 requires the entire element visible, which fails for cards taller than the viewport or ones near the page edges. 0.4 is a practical middle ground: the card has to be almost half visible, which in practice means the user is actually looking at it.

Per-card formatting via CountUp's options object

js new countUp.CountUp(id, cfg.end, Object.assign({ duration: 2 }, cfg.options))

Each stat carries its own options — separator: ',' for the thousands-grouped user count, decimalPlaces: 2 for a precise uptime percentage, a plain suffix: 'ms' for latency, decimalPlaces: 1 and suffix: '/5' for a rating. CountUp.js parses these once at construction and handles the number formatting (locale separators, decimal rounding, prefix/suffix concatenation) internally on every animation frame — you never touch toFixed() or manual string building. counter.error is checked before calling .start() because CountUp validates its inputs at construction and sets .error to a message string (rather than throwing) if, say, the target element doesn't exist — checking it is a cheap guard against a silently-broken counter.

Reusing it

Add a fifth card by appending one object to statConfig and one matching id/span pair in the HTML — the observer setup loop and the formatting all generalize automatically.

Step by step

How to Use

  1. 1
    Add the CountUp.js CDNInclude the countUp.umd.js build from the CDN panel — it exposes the global countUp.CountUp.
  2. 2
    Paste HTML, CSS, and JSFour stat cards render with all values at 0, below a tall spacer.
  3. 3
    Scroll the cards into viewEach number animates from 0 to its target once it crosses 40% visibility, with its own formatting.
  4. 4
    Scroll away and backNumbers stay at their final value — the started guard and unobserve() prevent a re-count.
  5. 5
    Add a new statAppend one entry to statConfig and one matching span with that id in the HTML.
  6. 6
    Adjust the trigger pointChange threshold (currently 0.4) to fire earlier or later relative to scroll position.

Real-world uses

Common Use Cases

Marketing by-the-numbers
Show classic social-proof statistics that begin counting only as each card scrolls into view, using one shared IntersectionObserver for all four cards.
Investor and about pages
Present revenue, users or growth metrics that tick up when the reader arrives, with a fire-once guarantee from a started map plus unobserve().
App rating highlights
Show a rating stat that counts up beside review excerpts, with each card passing its own separator, decimal places and prefix options.
Case study results
Quantify an outcome such as 42 percent faster, with a visibility threshold of 0.4 that avoids firing on a barely visible edge.
Event recaps and observer learning
Show attendee counts and sessions for a conference recap, and study a clean IntersectionObserver pattern that waits for the reader before starting.

Got questions?

Frequently Asked Questions

IntersectionObserver is designed to batch multiple targets under a single callback efficiently — creating four separate observer instances would work but adds overhead for no benefit, since the callback already receives an entries array you can iterate and branch on by target.

Two layers: a started object keyed by element id that the callback checks first and sets before doing anything else, and observer.unobserve(entry.target) called right after starting the count, which removes that element from observation entirely so the callback can't even fire for it again.

It's a practical middle ground. threshold: 0 fires the instant even one pixel is visible, which can trigger a card that's barely peeking at the viewport edge; threshold: 1 requires full visibility, which fails for any card near the top/bottom edge or taller than the viewport. 0.4 means roughly half the card must be visible, which in practice matches when a user is actually looking at it.

Each entry in statConfig carries its own options object (separator, decimalPlaces, prefix, suffix) that gets merged with a shared duration and passed straight to CountUp's constructor. CountUp handles all the formatting internally on every animation frame, so there's no manual toFixed() or string concatenation anywhere in this code.

CountUp validates its constructor arguments (like confirming the target element id actually exists in the DOM) and, instead of throwing, sets an .error string property when something is wrong. Checking !counter.error before calling .start() is a cheap way to avoid silently animating nothing if a card's id is ever mistyped or missing.

Create the CountUp instance inside a useEffect (React) or onMounted (Vue) once the ref'd DOM node exists, and set up the IntersectionObserver in the same lifecycle hook, disconnecting it in the cleanup function. Keep the started guard as component-level state or a ref so it survives re-renders without restarting the count.