How the Odometer carries
The Odometer is a mechanical drum counter for the web. On a real odometer a drum only moves while the one to its right is carrying, so 1,099 to 1,100 rolls the last three drums over together, right at the end of the units' turn. Most animated counters move every digit on its own, so nothing carries. This page is how mine does it. It's free for everyone, MIT, on npm as tactile-odometer.
Written 29 September 2026. Vanilla ES module and CSS, no dependencies, 42.3 KB gzipped with its font, 24 tests.
One number, not one animation per digit
The counter animates a single continuous value. A new value is a spring target: the value springs toward it, and every drum's position is worked out from that one number on every frame. That's what makes the carry possible. If each digit had its own tween, the tens would start turning on the first frame, long before the units reach 9.
The carry rule
Only the last drum is driven. Drum k turns only while drum k-1 passes from 9 to 0. The first carry takes the last tenth of the units' step, and every carry above it turns in lock-step with the drum below, so all the carries land together.
export function drumPositions(value, count, decimals = 0, carry = CARRY) {
const n = scaled(value, decimals);
const out = new Array(count);
if (count < 1) return out;
let below = n % 10;
out[0] = below;
for (let k = 1; k < count; k += 1) {
const w = k === 1 ? carry : 1;
const digit = Math.floor(n / 10 ** k) % 10;
below = digit + clamp((below - 10 + w) / w, 0, 1);
out[k] = below;
}
return out;
}
below is the position of the drum under this one. When it's past 10 - w, this drum is carrying, and the clamp says how far through the carry it is. CARRY is 0.1, the last tenth. scaled() removes floating-point dust first, so 10.1 at two decimals is 1010 and not 1009.9999999999999.
At 1,099.5 the tens and hundreds haven't moved. At 1,099.95 they are both half way, turning together. The tests check both.
Big jumps
A jump of millions would make the low drums strobe. So a drum turning faster than the spin cap (24 digits a second by default) is drawn turning at the cap, smeared along its turn, while the slower drums visibly roll. Every drum still lands exactly on its digit.
Light that doesn't turn
A drum is a cylinder on a horizontal axle, so the light on it depends only on height in the window, never on how far it has turned. I compute one overlay per drum from the lamp, once, with the darkening toward the top and bottom and the band of reflection along the drum. The numerals roll through it. The faces are real DOM text on a ten-sided CSS 3D drum, so they stay crisp and foreshorten properly.
Text, motion and cost
- The drums are
aria-hidden. The number is visually hidden text, announced politely once the value has landed, never each frame. - With reduced motion the value changes instantly and no frame is requested.
- The loop runs only while a value is travelling and sleeps once it lands. It pauses off screen and in a hidden tab, and a tab coming back after two seconds can't blow up the spring.
- The numerals are Big Shoulders Bold, bundled, loaded with
font-display: blockand re-centred on the real face once it arrives, so nothing jumps.
Use it
npm install tactile-odometer
The source, the tests and a demo page are at github.com/cipriantitire/tactile-odometer. You can play with it on its object page. How it compares with odometer.js and NumberFlow is on its own page.