Scroll✨ Premium

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.

JavaScriptAnimationData

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

Carbon footprint indicators in a digital CSR report — Number Counter Scroll example 1

① Carbon footprint indicators in a digital CSR report

WhenThe "Environmental impact" section of an online corporate responsibility report: six figures — tonnes of CO₂ avoided, share of renewable energy, water recycled, local jobs, certified suppliers, kilometres by soft mobility — in a grid that follows the chapter text, with counters starting from zero as soon as it enters the reading area.
WhyA value climbing from 0 to 918 over 125 frames makes the order of magnitude tangible: the reader watches the figure build, which a value shown outright does not do and a progress bar does no better — there is no maximum to reach. The linear step suits a document: no theatrical acceleration, a steady climb and a clean stop on the target.
SettingsThree-column grid (grid-template-columns: repeat(3, 1fr)), green gradient #4ade80 → #22d3ee, units in a <small> next to the counter rather than in data-suffix (never read), values of four digits at most to do without a thousands separator, labels at 55% white. Trigger: one observer, root = the reading area, threshold .3, once.
Season statistics on an amateur sports club's official site — Number Counter Scroll example 2

② Season statistics on an amateur sports club's official site

WhenThe "Season review" section of a football club's site: matches won, goals scored, pass completion rate, spectators, final ranking — read on a phone after a match, below the day's score, in a narrow column.
WhyThe trigger you add fires once — unobserve on first entry — and nothing resets the counters to zero: a supporter who scrolls back up to the score and down again finds the figures settled. Each statistic is revealed once, through its full climb, like a final result that is not replayed; tabular digits (tabular-nums) keep the column from jittering while counting.
Settings288 px scrolling area inside a phone frame, two-column grid kept with the last card spanning the row (grid-column: 1 / -1), solid white digits (color instead of the gradient and -webkit-text-fill-color), ranking "2" followed by a <small>nd</small>, threshold .4.
Municipal election results on a local authority's website — Number Counter Scroll example 3

③ Municipal election results on a local authority's website

WhenThe "Results" page of a town hall site after a vote: registered voters, votes cast, spoilt ballots, turnout — four official figures of equal weight, below the minutes, animated together when the section enters the citizen's screen.
WhyThe four counters start on the same frame and reach their target on the 125th: the reveal is simultaneous, with no hierarchy. Progress bars would have given each figure a length — and therefore a visual ranking between officially equivalent results. Here only the value changes; the frame is identical for all four.
SettingsFour cards in a row (repeat(4, 1fr)), solid institutional colour (#1e3a8a on a light background, gradient removed), percentage and units in a sibling element, threshold .5 so the row is half visible before it starts, aria-label with the final value on each card.

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)').matches is tested at init and short-circuits the animation — the final value and its suffix are written directly. The pulseHint pulse can be stopped under the same media query with @media (prefers-reduced-motion: reduce) { .scroll-hint-label { animation: none } }.
  • A counter is a div rewritten up to 125 times in one to two seconds: a screen reader landing on it mid-climb reads an intermediate value, and an aria-live region would stutter. Do not put aria-live on .counter-value; give the final value in an aria-label on .counter-stat ("2847 users") and mark the animated digits aria-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-label labels 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 to rgba(255,255,255,.55) (5.4:1) to meet the criterion.
  • The .scroll-container area is not focusable by default: with a keyboard it only scrolls after a click inside. Add tabindex="0" and an aria-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-label is 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.

Chrome 120+✓ Full
Firefox 61+✓ Full (system scrollbar: ::-webkit-scrollbar ignored)
Safari 14+✓ Full
Edge 120+✓ Full
Mobile iOS 14+✓ Full (touch scrolling of the area)
Android Chrome 120+✓ Full

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):

index.html — structure
<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>
🔒 Unlock the full code — from €2.99 the first month

Full HTML + CSS + JS, copy-paste ready — with hundreds of premium effects.

Customize

Options passed to the API or data-* attributes:

Option / propertyDefaultEffect
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

Because the first grid sits 67 px from the top of the scrolling area (280 px tall): it is already visible on load. The 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.
Same step — fx-0544 writes 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.
Because the duration is counted in frames, not in time: 125 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.