Skip to the page
Tactile UI

How a digital odometer carries, like the real mechanism

This is about a component I made, the Tactile UI Odometer, so read it knowing that. It's free and MIT. Everything here comes from its source. The drum timings are from the headless Chromium run in my counters comparison, and the spring numbers come from stepping the same functions in Node. I haven't tried it in Safari or Firefox, or with a screen reader. If something here is wrong, email me and I'll fix it.

What a real odometer does

An odometer is a row of drums with 0 to 9 printed round them. Only the last drum is driven. A drum to its left moves only while the drum to its right goes from 9 to 0.

So on a car going from 1,099 to 1,100 miles, the tens and hundreds don't start turning when the units do. They wait until the units drum is almost at the end of its turn, and then three drums roll together: units 9 to 0, tens 9 to 0, hundreds 0 to 1.

Most web counters can't do this, because each digit has its own transition, and the tens digit starts when the number changes, not when the units come round.

One number, not one animation per digit

The fix is to stop having a transition per digit. The Odometer animates a single continuous value. A new value is a spring target, and on every frame every drum's position is worked out from that one number, so the carry can't drift out of step with it.

The carry rule

This is the whole rule, from the source:

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;
}

Index 0 is the last drum. A position runs from 0 to 10: 3.0 shows a 3, 3.5 is half way to the 4. The last drum sits at value % 10. For every drum above it, below is the position of the drum underneath, and the clamp is the trick: (below - 10 + w) / w is 0 until that drum gets within w of the end of its turn, then climbs to 1 as it arrives. Add it to the drum's own digit and you have its position.

w is the width of the carry. For the first drum above the units it's CARRY = 0.1, the last tenth of the units' step. For every drum above that it's 1, the whole turn of the drum below. The tens drum only turns during that last tenth, so the hundreds, whose turn is the tens' whole turn, fits inside it too, and so do the thousands. All the carries land together.

Here are four values on four drums, from running drumPositions in Node:

ValueUnitsTensHundredsThousands
1099.59.509.000.001.00
1099.959.959.500.501.00
1099.999.999.900.901.00
11000.000.001.001.00

At 1099.5 nothing above the units has moved. At 1099.95 the tens and hundreds are both half way, turning together.

In the browser, with the default spring, the units drum had done 5%, 50% and 95% of its travel at 19, 117 and 300 ms. The tens and hundreds hit those marks at 268, 300 and 417 ms: the units are nearly through before the other two begin.

The value goes through scaled() first, which strips floating-point dust (10.1 at two decimals is 1010, not 1009.9999999999999), and there are at most 15 drums, since more digits than a double holds exactly would show a last digit that isn't true.

A spring on the value

The value moves toward its target with a damped spring:

export function stepSpring(s, target, dt, stiffness = 140, damping = 0.85) {
  const c = 2 * damping * Math.sqrt(stiffness);
  let left = clamp(dt, 0, 1 / 30);
  while (left > 1e-6) {
    const h = Math.min(left, 1 / 120);
    s.v += (stiffness * (target - s.x) - c * s.v) * h;
    s.x += s.v * h;
    left -= h;
  }
  return s;
}

s.x is the value and s.v its velocity. Stiffness is 140 and damping is a ratio, 0.85: below 1 it overshoots a little and settles back, at 1 or more it arrives without passing. Both are options.

The frame time is clamped to 1/30 s and cut into steps of at most 1/120. A tab that's been in the background comes back with a gap of seconds, and without the clamp the spring would take one huge step and fling the drums.

A spring is linear, so the shape of the motion doesn't depend on the distance. At 60 Hz in Node, 1,099 to 1,100 reaches 95% of its distance in 317 ms, and so does 0 to 1,000,000. Only the settling differs, 700 ms against 1,950 ms. The default overshoot is 0.23% of the distance: 0.002 of a digit for one step, which you can't see, but about 2,250 for a jump of a million, so the drums pass 1,000,000 and wind back. I think that looks right for a heavy thing that's been spun, and damping 1 removes it.

Drawing a drum

Given a position from 0 to 10, the rest is geometry. A drum is ten faces round a horizontal axle, each one real text, so the figures stay crisp and foreshortened by the browser. Face i is rotated wrapDelta(pos - i) * 36 degrees from the front (wrapDelta folds a distance into -5 to 5, so face 9 is one step from face 0), and the radius that makes ten 1.12em faces meet is 1.12 / (2 * tan 18°), 1.7235em.

Faces more than 47 degrees from the front are hidden, since the window shows 27 degrees of the drum and every visible face costs a layer. Each face carries its own transform under a flat parent with perspective, not inside a rotating preserve-3d drum, because in Chrome the nested version culled the face above the axle at some viewport sizes and left a black cap on every drum at 0. The light doesn't turn: it depends only on height in the window, so it's worked out once per drum and the numerals roll through it.

Big jumps

Roll from 0 to 1,000,000 and, in the same Node run, the units drum peaks near 4.9 million digits a second. At 60 frames a second a frame skips far more than half a digit, so the figures strobe and can seem to run backwards.

So there's a spin cap, maxSpin, 24 digits a second by default. A drum faster than the cap is drawn turning at the cap:

export function spin(phase, truePos, speed, dir, cap, dt) {
  if (speed > cap && dt > 0) return { pos: wrap10(phase + dir * cap * dt), free: true };
  return { pos: wrap10(truePos), free: false };
}

A drum's speed is the value's speed divided by ten for each place up. In that jump the six lowest drums are over the cap and free-run, and the millions drum peaks near 5 digits a second, so it rolls for real. When the value lands, every drum is back at its true position.

The blur is a second copy of the figure, smeared with stacked text-shadow: none below a third of the cap, all of it at the cap. A carry is fast even when the value is slow, so the blur uses the larger of the drum's speed and how far it moved this frame.

One number for screen readers

The drums are aria-hidden. The number is a visually hidden span with aria-live="polite" and aria-atomic="true", holding the formatted text, like "1,100 km". The first value is set before the element reaches the page, so nothing announces a number nobody changed.

What matters is when the text changes: once, after the spring has landed and the blur has finished.

if (done && !smeared) {
  this.#shrink();
  this.#paint(0);
  this.#announce();
  this.#sleep();
  return;
}

A screen reader hears "1,100 km" once, never a frame's worth of "1,043, 1,051, 1,062". A tally switches aria-live to off while it counts and refreshes the text at most once a second, and announce: false keeps a counter beside the real content silent. If the counter is off screen or the tab is hidden, a new value jumps straight to its target, since nobody can watch that roll and the text shouldn't wait. I haven't tested this with a real screen reader; it follows how aria-live is documented.

Reduced motion

The component reads prefers-reduced-motion and listens for it to change. With it on, setValue moves the spring to the target, repaints once and updates the text. No frame is requested, so there's no loop. If the preference flips mid-roll, it jumps to the end. A tally still counts, in steps on a timer of no less than 100 ms, with no animation between them.

Numbers from Intl.NumberFormat

Separators are fixed plates between drums, so the component has to know where they go. It formats a fifteen-digit number with Intl.NumberFormat and reads the group sizes from formatToParts: breaks at 3, 6, 9 in en-US, 3, 5, 7 in en-IN. The group and decimal marks, the minus sign and the ten digit glyphs come from the same parts, an unknown locale falls back to en-US, and the screen reader text uses the same formatter. The limit is the font: the face I bundle has Latin digits only, so a locale like ar-EG would ask for glyphs it doesn't have. I haven't tried one.

The numerals font

The figures are Big Shoulders Bold (SIL Open Font License 1.1), shipped beside the stylesheet as a 20,708 byte WOFF2 with its licence, so the drums print the same figures on every platform. It loads with font-display: block, so for a few milliseconds the drums show no figures instead of a fallback that jumps. Digits stand above the descender space, which sits them high in a centred line box, so the component measures the real face on a canvas, moves the ink to the centre line, and measures again once the face has loaded.

What it costs

The script is about 9.5 KB minified and gzipped (the npm package ships it unminified, with its comments), the stylesheet about 4 KB, the font 20.7 KB, no dependencies. That's heavier than the text counters I compared, and the font is most of it. At rest it costs nothing: the loop only runs while something moves, and it pauses off screen and in a hidden tab. It's plain JS and a custom element with no React or Vue package, so in a framework you create it in an effect and call destroy() on unmount.

Use it

It's free and MIT: a playable version at /objects/odometer, tactile-odometer on npm, and in the shadcn directory:

npx shadcn add @tactile/odometer
import Odometer from 'tactile-odometer';
import 'tactile-odometer/odometer.css';

const odo = new Odometer(document.getElementById('counter'), { value: 1099 });
odo.setValue(1100);                      // rolls, carrying
odo.setValue(1100, { instant: true });   // jumps without spinning

If you'd rather build your own, it fits in a page: one spring on the value, every drum computed from it with the carry rule, and the text changed only when it lands. The Odometer is one piece of a larger library, Tactile UI, but you don't need the rest for this one.