webdevpuneet.com

Scroll-Synced Odometer — Free Scroll-Scrubbed Mechanical Digit Roll

Scroll-Synced Odometer · Scroll · Plain HTML, CSS & JS · Live preview

Copy & paste

Source Code

<section class="od-intro"><h1>Scroll ↓</h1><p>Digits roll like a mechanical odometer — but scroll position drives the roll directly, not a timer.</p></section>
<section class="od-wrap" id="odWrap">
  <div class="od-panel">
    <div class="od-label">Total distance traveled</div>
    <div class="od-odometer" id="odOdometer"></div>
    <div class="od-unit">kilometers</div>
  </div>
</section>
<section class="od-outro"><p>Scroll back up — every digit unrolls exactly in reverse.</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

Scroll-scrubbed roll
Digit position is a direct function of scroll progress.
Real per-digit reels
Each digit is a translating strip of all ten numerals.
Exactly reversible
No reset logic — the same formula rolls back naturally.
No animation timer
Position is recalculated fresh on scroll, not eased over time.
Fade mask edges
Gradient overlays mimic a real mechanical reel window.
Auto-generated digits
Reels build automatically from the TARGET number's length.
rAF-gated scroll listener
One recalculation per frame regardless of event frequency.
Sticky scrub panel
CSS position: sticky provides the pinned viewing window.

About this UI Snippet

Scroll-Synced Odometer — Digits That Roll in Exact Sync With Scroll

Screenshot of the Scroll-Synced Odometer snippet rendered live

A typical odometer count-up plays over a fixed duration once triggered — press play, watch it spin, done. This one has no duration and no timer at all: each digit's roll position is calculated directly from the current scroll progress on every scroll event, so scrolling exactly a third of the way through the section shows every digit exactly a third of the way through its roll, and scrolling backward reverses it precisely, digit by digit. Built with vanilla JavaScript reading getBoundingClientRect(), no animation library required.

A real reel per digit

For each digit of the target number, the code builds a .od-digit-strip containing all ten numerals 0 through 9 stacked vertically, clipped inside a fixed-height .od-digit window with overflow: hidden — the same physical structure a real mechanical odometer wheel has. "Rolling" a digit is just translating its strip upward by a multiple of the digit's pixel height; no digit ever needs its text content swapped, only its transform.

Roll position as a direct function of scroll

The core formula is position = progress * finalDigit, where progress is the 0–1 scroll fraction through the tall wrapper section and finalDigit is that digit's target value (0–9). At progress = 0.5, a digit whose final value is 8 sits translated to show digit 4 — exactly halfway through its roll from 0 to 8. This is what makes the effect a true scrub rather than a triggered animation: there's no requestAnimationFrame easing loop advancing toward a target over time, only a recalculation from the current scroll position on every scroll event.

Genuinely reversible, no reset logic needed

Because the transform is recomputed fresh from progress every time rather than accumulated, scrolling back up doesn't need any special-cased "reverse" branch — the same formula that rolled the digit forward naturally rolls it back as progress decreases. This is the same underlying pattern as a scroll SVG line chart draw, just applied to digit reels instead of a stroke offset.

Fade masks for realism

Small top and bottom gradient overlays (::before/::after) on each digit window fade the numeral into the background near the edges, mimicking the subtle motion blur and depth a real mechanical reel window has, without any extra JavaScript.

Customizing it

Change TARGET to any number, add a comma-separated thousands grouping between digit groups, or use Math.floor on position if you'd rather have digits click into place at whole-number scroll increments instead of continuously interpolating between them. Pair it with gsap-scroll-number-counter or odometer stat counter to compare against a timed roll.

Step by step

How to Use

  1. 1
    Paste HTML, CSS, and JSAn intro, a sticky odometer panel, and an outro render.
  2. 2
    Scroll into the odometer sectionDigits roll upward toward their final values as you scroll.
  3. 3
    Scroll slowly and watchEach digit's position tracks scroll progress exactly, not a timer.
  4. 4
    Scroll back upDigits unroll in exact reverse, with no reset needed.
  5. 5
    Change the target numberEdit the TARGET constant to any value.
  6. 6
    Adjust the scroll distanceChange .od-wrap's height for a longer or shorter roll.

Real-world uses

Common Use Cases

Milestone statistics
Roll distance, download or usage totals in proportion to scroll, with each digit as a strip of ten numerals translated directly by progress.
Annual report contrast
Contrast with the timer-driven odometer stat counter, where scroll scrubs the roll and no duration exists.
Product landing figures
Reveal a big number as part of the story, exactly reversible because the same formula simply rolls back on upward scroll.
Fundraising trackers
Sync a running total to how far the reader has scrolled, pairing with a GSAP scroll number counter for different stats.
Scrollytelling pairings
Combine with a scroll SVG line chart draw so a chart and its headline number build together.
Related: Scroll Progress Journey Trail
See the Scroll Progress Journey Trail for a related scroll pattern worth pairing with this one.

Got questions?

Frequently Asked Questions

It is genuinely scroll-driven. There is no requestAnimationFrame easing loop advancing toward a target over a fixed duration; instead, on every scroll event the code measures the current scroll progress and directly sets each digit strip's transform to progress times that digit's final value times the digit height. Scrolling to exactly the halfway point of the section leaves every digit exactly halfway through its individual roll.

Because the transform is a pure function recomputed from the current scroll progress rather than an accumulated or eased value, decreasing progress naturally decreases the computed translateY on every strip — the same formula that rolled digits forward rolls them back when scroll direction reverses, with no special-cased reverse logic required anywhere in the code.

For each digit of the target number, the code creates a .od-digit-strip element and appends ten span children showing 0 through 9 in order, then places that strip inside a fixed-height .od-digit container with overflow: hidden. Rolling the digit is simply translating the strip upward by a multiple of one digit's pixel height (DIGIT_HEIGHT) — the numeral text content is never swapped, only the strip's position.

Continuous interpolation (position = progress * finalDigit, without rounding) makes the roll feel physically connected to your exact scroll speed and position, similar to a real mechanical odometer wheel mid-turn between two numerals. If you prefer digits to click into whole values only, wrap the position calculation in Math.floor or Math.round before applying the transform.

Generate the digit strips in a mount effect (or render them declaratively from an array of 0-9 per digit), attach refs to each strip element, and in the same effect attach the passive scroll and resize listeners running the same rAF-gated progress calculation, writing transforms directly to the ref elements. Return a cleanup that removes both listeners.