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

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

Share & Support

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.

Build with AI

Build, Understand, Optimize, and Extend It With AI

Paste this snippet's JS into an AI coding assistant like Claude and ask it to explain why the formula position = progress * finalDigit, recalculated fresh from the current scroll position on every scroll event, produces an odometer roll that is exactly reversible when scrolling backward — with no separate "reverse" code path needed anywhere — in contrast to a timed count-up animation that plays once and would need extra logic to be scrubbable. It's also useful for extending the effect: ask it to add thousands-separator commas between digit groups without breaking the per-digit reel structure, or to make each digit's roll speed slightly different (e.g. the ones digit rolling faster relative to scroll than the hundred-thousands digit) for a more mechanically realistic feel, similar to how a real odometer's wheels interlock.

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:

text
Build a "scroll-synced odometer" effect in plain HTML, CSS, and vanilla JavaScript (no libraries).

Requirements:
- A tall wrapper section (e.g. 300vh+) with a sticky inner panel (position: sticky) that stays pinned in the viewport while the wrapper provides scroll room.
- For a target number (e.g. 184260), programmatically build one "reel" per digit: each reel is a fixed-height, overflow-hidden container holding a vertical strip that contains all ten numerals 0 through 9 stacked in order. This must be built with actual DOM elements for each numeral, not a single text node that gets replaced.
- On scroll, compute a 0-to-1 progress value from the wrapper's getBoundingClientRect (scrolled distance divided by total scrollable distance, clamped to 0-1). For each digit reel, calculate its roll position as progress multiplied by that digit's final target value (0-9), then set the strip's transform to translateY(-position * digitPixelHeight). Do NOT use a fixed-duration animation loop (no easing toward a target over time) — the transform must be a direct, recalculated-every-time function of the current scroll progress only.
- Gate the scroll handler with a requestAnimationFrame flag so it recalculates at most once per frame, using a passive scroll listener.
- Add subtle top and bottom gradient fade overlays on each digit's window to mimic a real mechanical reel's depth.
- Confirm the behavior by scrubbing: scrolling to exactly the halfway point of the wrapper section should show every digit exactly halfway through its individual 0-to-target roll, and scrolling back up should reverse every digit's position exactly and immediately, with no lag, easing, or special reverse-case code.

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

  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 stats
Distance, downloads, or usage totals tied to reading pace.
Annual report pages
Contrast with odometer stat counter's timed roll.
Product landing pages
Reveal a big number as part of the scroll narrative.
Fundraising trackers
Sync a running total to how far the reader has scrolled.
Data-heavy dashboards
Scrollytelling pieces

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.