Odometer Gagan

Digit-by-digit rolling number animation. Each digit independently rolls from 0 to its target value with staggered delays, creating the tactile feel of a physical counter. Indian number grouping (1,000 → 10,000 → 1,00,000). Used on hero balance cards where a live amount change should feel satisfying.

Sizes

XL — 48px — hero balance
420
LG — 36px — card secondary
1,280
MD — 24px — inline amount
14,500

Color variants

4,200
Teal — you are owed
1,850
Coral — you owe
0
Ink — settled / zero

Indian number grouping

999 up to 999 — no comma
1,000 1,000 → group from right in 3
10,000 10,000 → still one comma at 3
1,00,000 1,00,000 → Indian lakh grouping

Use toLocaleString('en-IN') to format the number before splitting into digit columns. The comma characters are separate DOM elements with reduced opacity, not part of the digit strip.

Usage

Do Use odometer for balance amounts that change as a result of user action — settling an expense, adding a new split, refreshing the home card. The animation communicates that the number just updated and draws the eye to the new value.
Don't Don't use odometer for static display amounts. For amounts that never animate — expense rows, ledger entries, split breakdowns — use the amount-display component instead. The rolling animation on static content is distracting noise.
Do Trigger the roll animation only when the underlying value actually changes. Replaying the animation on every mount (page load, route change) reads as a glitch, not a feature.
Don't Don't animate from scale(0) or opacity: 0 — the digit strip scrolls into view from below. Never use transition: all; enumerate only transform with the exact timing values.

Inconsistency note

Font mismatch Odometer uses JetBrains Mono (monospaced, tabular) while amount-display uses Bricolage Grotesque (display). Both components show rupee amounts, which creates an inconsistency in contexts where both appear near each other (e.g. a hero card odometer above a ledger-row amount). Recommendation: keep both available — mono for live/tabular contexts where digit alignment matters, display for hero amounts where typographic weight is more important than column alignment. Document the intended context for each at the call-site.

Spec

PropertyValueNotes
FontJetBrains Monofont-variant-numeric: tabular-nums
Font weight600Currency prefix at 500
Animation duration780msPer digit
Easingease-outcubic-bezier(0.23, 1, 0.32, 1)
Stagger35ms per digitLeft-to-right, leading digits first
DirectionBottom to topDigit strip scrolls upward into final position
Number formatIndian groupingtoLocaleString('en-IN')

Size tokens

VariantFont sizeUse
odometer-xl48pxHome hero card primary balance
odometer-lg36pxGroup card secondary balance
odometer-md24pxInline amounts in section headers
odometer-sm18pxCompact contexts

Accessibility

Screen readers Wrap the entire odometer in an element with aria-live="polite" and aria-atomic="true". Include a visually hidden full text value so screen readers announce the complete amount (e.g. "₹1,280") rather than reading individual digit columns. Use aria-label on the container: aria-label="Balance: ₹1,280".
Reduced motion Respect prefers-reduced-motion: reduce — skip the digit scroll animation and display the final value immediately. The number itself must still update; only the animation is suppressed.

Code

HTML structure

<!-- Each digit is a column; the strip inside scrolls vertically -->
<span class="odometer odometer-xl odometer-teal"
      aria-live="polite" aria-atomic="true"
      aria-label="Balance: ₹1,280">
  <span class="odometer-currency"></span>
  <span class="odometer-digits">
    <span class="digit-col">
      <span class="digit-strip" style="transform: translateY(-100%)">
        <span>0</span>
        <span>1</span>
      </span>
    </span>
    <span class="odometer-comma">,</span>
    <!-- remaining digits ... -->
  </span>
</span>

Animation logic (pseudocode)

// Build digit strip: 0 through target digit, stacked vertically
// translateY to show the final digit at time=0 (resting state)
// On value change: reset to 0, then animate to new target
function rollDigit(col, targetDigit, delay) {
  const strip = col.querySelector('.digit-strip');
  // Build 0..targetDigit spans
  strip.style.transition = 'none';
  strip.style.transform = 'translateY(0)';
  // Force layout
  strip.offsetHeight;
  strip.style.transition = `transform 780ms cubic-bezier(0.23,1,0.32,1) ${delay}ms`;
  strip.style.transform = `translateY(-${targetDigit * 100}%)`;
}

Design tokens used

TokenValueRole
--font-monoJetBrains MonoDigit typeface with tabular figures
--teal#0E7C66Owed-to-you color variant
--coral#E76F51You-owe color variant
--ink#171513Neutral / settled color variant

See it in context

Interactive prototype — this component is live below. Tap, swipe, and drag to explore.

The odometer renders inside the typographic balance visualization. Use the tweaks panel to switch viz to "typographic" and then toggle states to see the digit-strip animation.