Counter Odometer
Odometer counter: each digit rolls inside its cell to its place when the counter appears, then on every new value.
Updated
This effect is part of Effect.Labs — 840 vanilla effects, some free, some premium. Data has 28 effects, including 1 free. Explore the category →
3 usage examples



How it works
The HTML comes down to <div class="odom-counter" data-value="12487">12,487</div>. The script formats the value in the page language ("12,487" in English, "12 487" in French), then builds one .odom-cell per digit holding a vertical 0-to-9 strip (.odom-strip), and a narrower cell for each separator (.odom-sep). All strips start on 0.
When the counter is half visible (IntersectionObserver, once), each strip slides by 10% of its height per digit, over 0.8 s with a strong ease-out, each one 120 ms after the previous (first start at 300 ms). Writing a new value to data-value later rolls all cells together to the new digits; if the number of digits changes (9,999 → 10,000), the cells are rebuilt and roll from 0.
The value is set only through data-value, from your HTML or your code; the component shows no button. Each counter is independent and nothing is declared on window.
Accessibility
- One number, not fifty digits: the strips are hidden (
aria-hidden="true") and an invisible.odom-srtext holds the formatted number. Screen readers read "12,487", updated on every newdata-value. - Context: the counter has no label of its own; put a text next to it ("visitors since 2019") so it means something.
- Reduced motion: under
prefers-reduced-motion: reduce, the transition is removed; digits are in place as soon as they appear and change without rolling. - Announcing changes: the counter is not a live region. If a new value must be announced, put
aria-live="polite"on a message of your own rather than on the counter, which would change too often.
Browser compatibility
Dependency-free JavaScript (about 3 KB), CSS transitions and CSS variables. Measured on 08/11/2026 in Chromium 151, Firefox 153 and WebKit 26.5 (Playwright): roll on appearance up to 12487, a counter further down waiting for the scroll, a new data-value (5021) on the 2nd copy without touching the 1st, read text "12,487", 390 px, reduced motion, no error. Versions taken from @mdn/browser-compat-data: IntersectionObserver sets the minimum.
Without IntersectionObserver, the digits are set on load. Without JavaScript, the HTML text ("12,487") shows plainly, without cells.
The code
HTML structure to paste into your page (the CSS + JS come with your purchase):
<div class="fx-0241-root">
<div class="odom-counter" data-value="12487">12,487</div>
</div>
<!-- built by the script inside .odom-counter: -->
<span class="odom-sr">12,487</span>
<span class="odom-row" aria-hidden="true">
<span class="odom-cell"><span class="odom-strip"><span>0</span> … <span>9</span></span></span>
<span class="odom-sep">,</span>
…
</span>
One-time payment, no subscription — the code is yours forever
Customize
Options passed to the API or data-* attributes:
| Option / property | Default | Effect |
|---|---|---|
Value (HTML — data-value on .odom-counter) |
12487 | Positive integer. Change it later in JavaScript (counter.dataset.value = 12500): the cells roll to the new value. |
Starting text (HTML — content of .odom-counter) |
12,487 | Shown only without JavaScript; keep it equal to data-value. |
Cell size (CSS — --odom-w, --odom-h, --odom-size) |
44px · 60px · 1.8rem | Cell width, cell height and digit size; shrink all three together for a small counter. |
Colours (CSS — --odom-color, --odom-cell, --odom-border, --odom-sep) |
#a78bfa · indigo at 15% · indigo at 30% · white at 30% | Digits, cell background and border, separator colour. |
Speed (CSS — --odom-duration) |
.8s | Roll time of each cell. The 120 ms offset between cells on first display is in the script. |
Separator (HTML — lang attribute) |
the page's | Comma in English, thin space in French. Set lang on the root to force one. |
Background and height (CSS — --odom-bg, --odom-height) |
#0a0a0f · 300px | Inside a page: --odom-bg:transparent; --odom-height:0px. |
FAQ
data-value to the nearest whole number and turns a negative number into 0, since each cell only rolls from 0 to 9. For a temperature or a balance, keep a plain text display.Intl.NumberFormat, minimumIntegerDigits option).data-value starts the roll again from the current position; with a 0.8 s cell, leave at least one second between two values so the roll can finish.