Number Counter Scroll
Six counters climbing from 0 to their target in steps of 1/125th of the value, one write per frame in requestAnimationFrame, no easing — 2.1 s at 60 Hz, 1 s at 120 Hz. A complete IIFE: IntersectionObserver on the scrolling area, one entry per grid, never replayed; data-suffix read and preserved on every frame; prefers-reduced-motion writes the target directly.
You'll get access to the interactive demo with a free account.
This effect is part of Effect.Labs — 811 vanilla effects, some free, some premium. Scroll has 70 effects, including 12 free. Explore the category →
3 usage examples



How it works
The sold HTML: a .demo-preview (centred flex, min-height: 300px, #0a0a0f background, sans-serif) holding a scrolling area .scroll-container#counter-scroll 280 px tall, overflow: hidden auto, scroll-behavior: smooth, WebKit scrollbar narrowed to 4 px. Inside, .counter-inner (24 px / 16 px padding) stacks two headings .counter-section-label (11.2 px, uppercase, white at 30%) and two two-column grids .st-counter-grid (gap: 16px): four .counter-stat cards then two. Each card (white at 3%, 5% border, 12 px radius) holds a .counter-value — 25.6 px, weight 800, #22d3ee → #6366f1 gradient clipped into the digits by background: … text + -webkit-text-fill-color: transparent, font-variant-numeric: tabular-nums — and a .st-counter-label (10.4 px, uppercase, white at 40%). Between the grids, a 200 px .counter-spacer and, after the second, an empty 200 px div: 838 px of content in a 280 px window, a 558 px travel. Each value carries data-target (2847, 99, 156, 42, 60, 100) and data-suffix (empty, %, k, empty, fps, empty). A pulsing .scroll-hint-label (pulseHint, 2 s, opacity .3 ↔ .7) sits at the bottom of .demo-preview.
The sold JS is an IIFE (no global variables). It opens with matchMedia('(prefers-reduced-motion: reduce)').matches and retrieves #counter-scroll. The animateCounter(el) function reads parseInt(el.getAttribute('data-target'), 10) and el.getAttribute('data-suffix') || ''; under reduce it writes t + s directly. Otherwise: l = t / 125, loop i += l — each frame writes Math.floor(i) + s, the final write places t + s. Measured on 2847: step 22.78 per frame (45, 68, 91 … 2824, 2846, 2847), 124 or 125 frames depending on floating-point rounding, 1.04 s in a Chromium running at 121 frames per second; on a 60 Hz screen 2.08 s. The suffix is preserved on every frame: "0%", "1%" … "99%", "0k" … "156k", "0fps" … "60fps".
The trigger: an IntersectionObserver rooted on #counter-scroll (threshold .3) observes each .st-counter-grid. On first entry it calls animateCounter on each .counter-value in the grid, then unobserve — the count is never replayed. The first grid sits 67 px from the top of the 280 px area: it is visible on load and its four counters start immediately; the second grid (at 510 px) waits for a scroll of at least 230 px. Under prefers-reduced-motion or without IntersectionObserver, the final value is written in one pass over all .counter-value elements. The IIFE ensures no conflict if another effect is pasted on the same page.
Accessibility
- prefers-reduced-motion handled:
matchMedia('(prefers-reduced-motion: reduce)').matchesis tested at init and short-circuits the animation — the final value and its suffix are written directly. ThepulseHintpulse can be stopped under the same media query with@media (prefers-reduced-motion: reduce) { .scroll-hint-label { animation: none } }. - A counter is a
divrewritten up to 125 times in one to two seconds: a screen reader landing on it mid-climb reads an intermediate value, and anaria-liveregion would stutter. Do not putaria-liveon.counter-value; give the final value in anaria-labelon.counter-stat("2847 users") and mark the animated digitsaria-hidden="true", or place the target in a visually hidden element. - Contrast on
#0a0a0f: the digits range from 10.9:1 (#22d3ee) to 4.4:1 (#6366f1) — fine at 25.6 px. The.st-counter-labellabels at 40% white measure 3.8:1 at 10.4 px, below the 4.5:1 threshold for body text; headings at 30% 2.6:1. Raise labels torgba(255,255,255,.55)(5.4:1) to meet the criterion. - The
.scroll-containerarea is not focusable by default: with a keyboard it only scrolls after a click inside. Addtabindex="0"and anaria-label. The intersection observer fires even without user scrolling (grid already visible) and at the keyboard — the first grid animates on load with no interaction needed. The.scroll-hint-labelis positioned relative to.demo-preview: without that wrapper it snaps to the bottom of the window, far from the container.
Browser compatibility
ES5 JavaScript (var, function), no dependency; it needs only requestAnimationFrame and IntersectionObserver. The constraint comes from the CSS: the text value in the background shorthand (unprefixed background-clip: text — Chrome and Edge 120+, Firefox 49+, Safari 14+) and the two-keyword overflow: hidden auto (Chrome 68+, Firefox 61+, Safari 13.1+). IntersectionObserver has existed since Chrome 51, Firefox 55 and Safari 12.1. Everything is classic JavaScript: the effect works in Firefox, unlike the native CSS scroll-driven animations (animation-timeline) in the same category.
Without JavaScript, the grid shows its initial values ("0", "0%", "0k"…) without animation. Before Chrome/Edge 120 (December 2023) or Safari 14, the background: … text shorthand is rejected as a whole: no gradient, but -webkit-text-fill-color: transparent still applies and the digits are invisible — to cover those versions, add -webkit-background-clip: text after the shorthand. Firefox ignores the ::-webkit-scrollbar rules and shows its native bar.
The code
HTML structure to paste into your page (CSS + JS available with a premium account):
<div class="demo-preview">
<div class="scroll-container" id="counter-scroll">
<div class="counter-inner">
<div class="counter-section-label">Project statistics</div>
<div class="st-counter-grid">
<div class="counter-stat">
<div class="counter-value" data-target="2847" data-suffix="">0</div>
<div class="st-counter-label">Users</div>
</div>
<div class="counter-stat">
<div class="counter-value" data-target="99" data-suffix="%">0%</div>
<div class="st-counter-label">Satisfaction</div>
</div>
<div class="counter-stat">
<div class="counter-value" data-target="156" data-suffix="k">0k</div>
<div class="st-counter-label">Downloads</div>
</div>
<div class="counter-stat">
<div class="counter-value" data-target="42" data-suffix="">0</div>
<div class="st-counter-label">Countries</div>
</div>
</div>
<div class="counter-spacer">
<p>Scroll to animate the counters</p>
</div>
<div class="counter-section-label">Performance</div>
<div class="st-counter-grid">
<div class="counter-stat">
<div class="counter-value" data-target="60" data-suffix="fps">0fps</div>
<div class="st-counter-label">Smoothness</div>
</div>
<div class="counter-stat">
<div class="counter-value" data-target="100" data-suffix="">0</div>
<div class="st-counter-label">Lighthouse score</div>
</div>
</div>
<div style="height: 200px;">
</div>
</div>
</div>
<span class="scroll-hint-label">Scroll to explore</span>
</div>
Full HTML + CSS + JS, copy-paste ready — with hundreds of premium effects.
Customize
Options passed to the API or data-* attributes:
| Option / property | Default | Effect |
|---|---|---|
Number of steps (JS — t / 125) |
125 frames (2.1 s at 60 Hz, 1 s at 120 Hz) | One frame per step: the duration is in frames, not milliseconds — it doubles at 30 Hz and halves at 120 Hz (measured: 1.04 s at 121 fps). 60 steps = one second at 60 Hz; 250 = four seconds. For a fixed duration, compute i = t × (performance.now() − start) / 2000 capped at t. |
Target (HTML — data-target) |
2847, 99, 156, 42, 60, 100 | Integer read by parseInt: "99.7" becomes 99, "2 847" becomes 2, "1.5" becomes 1. No thousands separator on output ("2847"): for "2,847", format both writes with new Intl.NumberFormat('en-GB').format(…). |
Suffix (HTML — data-suffix) |
empty, %, k, fps | Read on every write and concatenated to both textContent writes: during the climb each frame shows Math.floor(i) + s, the arrival places t + s. "0%" … "99%", "0k" … "156k", "0fps" … "60fps". To put the unit in a sibling element (<small>) rather than the attribute, leave data-suffix empty and write the unit in HTML — the three scenes on the examples page illustrate this approach. |
| Trigger (JS — to add) | IntersectionObserver on #counter-scroll, threshold .3, one entry per grid, never replayed | The observer is included in the sold code. Threshold 1 = the grid must be fully visible; 0 = one pixel is enough. To replay on every pass, keep observing and write "0 + suffix" back on exit (!isIntersecting). For a window-level trigger instead of the inner area, pass root: null. |
| Delay between counters (JS — to add) | 0 ms (simultaneous start per grid) | In the trigger, setTimeout(function () { animateCounter(el) }, k × 150) on the k-th counter gives a left-to-right cascade; 0 keeps the simultaneous reveal, fairer when the figures are of equal rank. |
Curve (JS — i += l) |
linear | Constant increment up to a hard stop. For a slow-down at the end, count the frames k and write i = t × (1 − Math.pow(1 − k / 125, 3)): the first digits fly, the last ones settle. |
Digit colour (CSS — .counter-value) |
135° gradient #22d3ee → #6366f1, 1.6rem, weight 800 | The gradient is clipped into the text (background: … text + -webkit-text-fill-color: transparent). For a solid colour, replace those two lines with color. Keep tabular-nums: without it, digit widths change every frame and the card jitters. |
Scrolling area (CSS — .scroll-container) |
280 px, overflow: hidden auto, scroll-behavior: smooth |
The demo container. In a real page, remove it and pass root: null to the trigger: the window becomes the root. scroll-behavior: smooth only affects programmatic scrolling and anchors. Without the .demo-preview wrapper, also remove .scroll-hint-label, which loses its reference box. |
FAQ
IntersectionObserver (root = #counter-scroll, threshold .3) detects it immediately and calls animateCounter on its four counters. The second grid is at 510 px and waits for a scroll of at least 230 px before entering the threshold. That is why the effect is named "Scroll": the trigger is consistent, but the second section only animates when the user scrolls down to it.target / (2000 / 16), 125 frames as well, same linear climb, same Math.floor — but a different dressing and lifecycle. Counter Scroll is isolated in an IIFE with one observer per card at threshold .5, a dataset.counted flag, a 150 ms cascade between cards, the suffix in a separate span that survives the count, and a rising fade on each card. Number Counter Scroll is the "dashboard grid" version: two titled sections, bordered cards, gradient digits and tabular-nums, a simultaneous start per whole grid. Pick fx-0544 for a four-figure strip with cascade and fade; this one for multi-grid statistics sections with simultaneous group reveals.requestAnimationFrame calls, one per screen refresh. Measured: 1.04 s in a Chromium at 121 frames per second; on a 60 Hz screen, 2.08 s; on a laptop in power-saving mode at 30 Hz, over four seconds; in a background tab the loop is suspended and resumes on return. For the same duration everywhere, store performance.now() at the start and derive the value from elapsed time (t × Math.min(1, (now − start) / 2000)) rather than accumulating a fixed step. You then also keep the same duration for 42 and for 2847, which the code already does: the step is proportional to the target.