webdevpuneet.com

Scroll Stat Reveal Story — Free Scroll-Triggered Counter Snippet (No Library)

Scroll Stat Reveal Story · Scroll · Plain HTML, CSS & JS · Live preview

Copy & paste

Source Code

<section class="srs-intro"><p>Scroll ↓</p><h1>A Year, In Numbers</h1></section>
<section class="srs-beat" data-count="4200000" data-suffix="+" data-prefix="$">
  <div class="srs-num" data-target="4200000">$0</div>
  <p class="srs-copy">raised across two funding rounds, entirely from customers who became believers first and investors second.</p>
</section>
<section class="srs-beat" data-count="98">
  <div class="srs-num" data-target="98" data-suffix="%">0%</div>
  <p class="srs-copy">of support tickets now resolved in under an hour, up from a painful 61% three years ago.</p>
</section>
<section class="srs-beat" data-count="140">
  <div class="srs-num" data-target="140" data-suffix="+">0</div>
  <p class="srs-copy">countries with an active paying team, spanning every inhabited continent but one.</p>
</section>
<section class="srs-beat" data-count="12">
  <div class="srs-num" data-target="12" data-suffix="M">0M</div>
  <p class="srs-copy">API requests handled on an average Tuesday, without a single planned outage this year.</p>
</section>
<section class="srs-outro"><p>Every number here started as a single customer, once.</p></section>

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

Zero dependencies
Built entirely on native IntersectionObserver and requestAnimationFrame.
Markup-driven data
Each stat's target and formatting live in data attributes, not a JS config array.
Middle-band trigger
Stats activate when centered in the viewport, not the instant they peek in.
Count-once guarantee
A WeakSet prevents re-triggering the count-up on repeat scroll passes.
Eased count-up curve
easeOutCubic timing makes large jumps feel snappy, not linear and slow.
Timestamp-driven animation
requestAnimationFrame tracks real elapsed time, consistent across refresh rates.
Automatic large-number formatting
Values format with locale thousands separators or an M short form.
Fully reversible reveal
The fade/rise-in of each beat still reverses on scroll-up, independent of counting.

About this UI Snippet

Scroll Stat Reveal Story — Big Numbers That Count Up as You Scroll

Screenshot of the Scroll Stat Reveal Story snippet rendered live

Annual reports, pitch decks, and "by the numbers" pages all lean on the same trick: a big number that counts up the moment it enters view, so a static fact reads as a small, satisfying event. This snippet builds that pattern with nothing but a native IntersectionObserver and requestAnimationFrame — no GSAP, no counter library, no dependency at all.

Data lives in the markup, not a JS array

Each .srs-beat section carries its own data-target, and its number element carries optional data-prefix/data-suffix attributes. animateCount() reads straight off whichever element triggered, so adding a new stat beat is purely an HTML edit — there's no parallel JavaScript array to keep in sync, unlike patterns that store all step data in one big config object.

A middle-band observer decides when to fire

Every beat is watched with rootMargin: '-30% 0px -30% 0px', shrinking the observer's effective trigger zone to the vertical center 40% of the viewport. A stat only starts counting once it's genuinely centered where a reader's eyes would be, not the instant its top pixel appears at the bottom of the screen — the same middle-band technique scrollytelling libraries like scrollama use for step detection.

Count-once, not count-every-time

A WeakSet tracks which number elements have already animated, so scrolling back up and back down again never restarts the count-up — a real annual-report stat shouldn't visibly reset and recount every time a reader scrolls past it twice. The is-active class (which drives the fade/rise-in of the number and copy) still toggles freely both directions, so the visual reveal itself remains fully reversible even though the counting animation itself only ever plays once per stat.

requestAnimationFrame with manual easing, not setInterval

Each count-up runs its own requestAnimationFrame loop tracking elapsed time against a fixed 1400ms duration, applying an easeOutCubic curve (1 - (1-t)^3) so large numbers start fast and settle gently rather than ticking linearly, which reads as sluggish for big jumps like the funding total. Because it's driven by a real timestamp rather than a fixed tick count, the animation runs at a consistent speed regardless of the visitor's refresh rate.

Honest large-number formatting

formatValue() renders values a million or above as a decimal-and-M short form (matching the 12M API-requests stat) and everything else with locale-aware thousands separators via toLocaleString, so a number like 4,200,000 counts through readable intermediate values instead of a flickering raw integer.

Customizing it

Add beats freely — each just needs a data-target and optional prefix/suffix; nothing else in the JS needs to change. Pair with a scroll number odometer for a persistent header counter, or a scrollytelling chart if the stats should also visualize as a graph.

Step by step

How to Use

  1. 1
    Paste the HTML, CSS, and JSNo CDN scripts needed — everything runs on native browser APIs.
  2. 2
    Scroll into the first stat beatThe number counts up from zero the moment it's centered in view.
  3. 3
    Keep scrollingEach subsequent beat counts up independently, once, the first time it centers.
  4. 4
    Scroll back upNumbers stay at their final counted value; only the fade/rise reveal reverses.
  5. 5
    Edit the data attributesChange data-target, data-prefix, and data-suffix per beat — no JS array to update.
  6. 6
    Add more beatsDuplicate an .srs-beat section; the IntersectionObserver picks it up automatically.

Real-world uses

Common Use Cases

Annual report and impact pages
Turn a list of yearly facts into a narrative sequence, with each big number counting up the first time it reaches the middle of the screen.
Pitch deck landing pages
Lead investors through traction numbers, with markup-driven data-target and data-suffix attributes holding each stat's value and format.
Nonprofit fundraising sites
Reveal donation totals and people helped, using a WeakSet so scrolling back never replays a count.
By-the-numbers sections
Show usage scale such as requests served or users reached, built with native observers and no animation library.
Case study and event recaps
Count up before and after results in a marketing case study, or reveal attendance and engagement figures on a conference recap page.
Related: Sticky Filter Bar
See the Sticky Filter Bar for a related scroll pattern worth pairing with this one.

Got questions?

Frequently Asked Questions

A WeakSet named counted tracks which number elements have already run their count-up animation. The IntersectionObserver callback checks this set before calling animateCount, so a beat that has already finished counting simply skips straight to toggling its is-active class again — the fade and rise-in reveal stays fully reversible, but the actual number never restarts from zero on a repeat pass, which matches how a real statistic should behave.

The default IntersectionObserver fires as soon as even one pixel of an element is visible, which for a full-height stat section means it would trigger while the number is still off-screen at the very bottom edge. Shrinking the observation zone with rootMargin: '-30% 0px -30% 0px' collapses it to the vertical middle 40% of the viewport, so a beat only activates once its number is genuinely centered where a reader is looking, matching the middle-band technique used by scrollytelling libraries like scrollama.

A single count-up animation on a single property (a text string derived from a number) doesn't need a full animation engine — requestAnimationFrame plus a manual easing formula does the same job with zero extra script weight. Reach for GSAP and ScrollTrigger instead when you need scrubbed, position-locked timelines (tied continuously to scroll position rather than firing once) or coordinated multi-property tweens, like the pinned line-fill in the scroll company timeline snippet.

Add or edit the data-prefix and data-suffix attributes on the .srs-num element for that beat — the prefix (like "$") is prepended and the suffix (like "%" or "+") is appended around whatever formatValue() produces. For values under a million, formatValue() uses toLocaleString('en-US') for thousands separators; for a million or more it automatically switches to a decimal-and-M short form and drops the suffix, matching the 12M API-requests stat.

Create the IntersectionObserver inside a mount effect (useEffect, onMounted, or ngAfterViewInit) after the beat elements have rendered, and keep a ref or component-scoped Set (rather than a module-level WeakSet) to track which stats have already counted, since a fresh WeakSet on every render would let numbers re-animate. Disconnect the observer in the cleanup function to avoid leaks across route changes.