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
| Property | Value | Notes |
|---|---|---|
| Font | JetBrains Mono | font-variant-numeric: tabular-nums |
| Font weight | 600 | Currency prefix at 500 |
| Animation duration | 780ms | Per digit |
| Easing | ease-out | cubic-bezier(0.23, 1, 0.32, 1) |
| Stagger | 35ms per digit | Left-to-right, leading digits first |
| Direction | Bottom to top | Digit strip scrolls upward into final position |
| Number format | Indian grouping | toLocaleString('en-IN') |
Size tokens
| Variant | Font size | Use |
|---|---|---|
odometer-xl | 48px | Home hero card primary balance |
odometer-lg | 36px | Group card secondary balance |
odometer-md | 24px | Inline amounts in section headers |
odometer-sm | 18px | Compact 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
| Token | Value | Role |
|---|---|---|
--font-mono | JetBrains Mono | Digit typeface with tabular figures |
--teal | #0E7C66 | Owed-to-you color variant |
--coral | #E76F51 | You-owe color variant |
--ink | #171513 | Neutral / 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.