You Might Also Like
Scroll Stat Reveal Story — Free Scroll-Triggered Counter Snippet (No Library)
Scroll Stat Reveal Story · Scroll · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
Scroll Stat Reveal Story — Big Numbers That Count Up as You Scroll

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.
Build with AI
Build, Understand, Optimize, and Extend It With AI
Paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain why the count-up logic reads its target value from a data attribute rather than a JavaScript array, and why a WeakSet is needed to make the count-up fire exactly once per stat while still letting the fade-in reveal remain fully reversible. The same assistant can help extend the pattern — ask it to add a small trailing sparkline under each stat showing the multi-year trend, sync a background color shift per beat the way scroll color sections do, or convert the count-up easing from easeOutCubic to a spring-like overshoot for a punchier finish. Treat it as a working, dependency-free base for your own metrics story.
Prompt to recreate it
Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:
Build a "scroll stat reveal story" in plain HTML, CSS, and JavaScript using only the native IntersectionObserver and requestAnimationFrame APIs — no external library, no GSAP, no bundler.
Requirements:
- A sequence of full-height narrative sections, each containing one large number element and a short paragraph of supporting copy, styled with a gradient text-fill on the number.
- Store each section's target numeric value, and optional prefix/suffix strings (for currency symbols, percent signs, or "+"/"M" style suffixes), directly as data attributes on the markup rather than in a separate JavaScript configuration array.
- Write a count-up function that uses requestAnimationFrame with a tracked start timestamp (not setInterval and not a fixed tick count) to animate from zero to the target value over roughly 1.4 seconds, applying an eased curve such as easeOutCubic so the count starts fast and settles smoothly rather than ticking at a constant linear rate.
- Format large values sensibly: numbers below one million should render with locale-aware thousands separators, and numbers at or above one million should render as a decimal value followed by an "M" suffix instead of the full digit string.
- Use a single IntersectionObserver with a rootMargin that shrinks its effective trigger zone to a band across the vertical middle of the viewport (for example -30% top and bottom), so a stat only becomes "active" once it is genuinely centered in the visible area, not the instant any pixel of it appears.
- Ensure each stat's count-up animation only ever plays once, the first time it becomes active, even if the visitor scrolls back up past it and down again — track already-animated elements in a Set or WeakSet — while still allowing the section's fade-and-rise-in visual reveal (a separate CSS class toggle) to remain fully reversible on every scroll direction change.Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.
Step by step
How to Use
- 1Paste the HTML, CSS, and JSNo CDN scripts needed — everything runs on native browser APIs.
- 2Scroll into the first stat beatThe number counts up from zero the moment it's centered in view.
- 3Keep scrollingEach subsequent beat counts up independently, once, the first time it centers.
- 4Scroll back upNumbers stay at their final counted value; only the fade/rise reveal reverses.
- 5Edit the data attributesChange data-target, data-prefix, and data-suffix per beat — no JS array to update.
- 6Add more beatsDuplicate an .srs-beat section; the IntersectionObserver picks it up automatically.
Real-world uses
Common Use Cases
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.