Odometer
A number whose digits roll like a mechanical counter, forward or back, the long way round.
import { useEffect, useMemo, useState } from 'react';
import {
StyleSheet,
Text,
View,
type StyleProp,
type TextStyle,
type ViewStyle,
} from 'react-native';
import Animated, {
FadeIn,
FadeOut,
LinearTransition,
useAnimatedStyle,
useSharedValue,
withDelay,
withSpring,
} from 'react-native-reanimated';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { durations, spring, springs } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
export type OdometerProps = {
value: number;
/** Fraction digits shown. Defaults to 0. */
decimals?: number;
/** BCP 47 locale for separators. Defaults to the device locale. */
locale?: string;
/** Text before the number, e.g. a currency sign. */
prefix?: string;
/** Text after the number, e.g. a unit. */
suffix?: string;
/** Full control over formatting. Digits 0–9 in the result roll; everything else is static. */
format?: (value: number) => string;
/** Extra delay per column, right to left, in ms. Defaults to 24. */
stagger?: number;
/** Announce changes to screen readers. Defaults to false. */
announce?: boolean;
style?: StyleProp<TextStyle>;
containerStyle?: StyleProp<ViewStyle>;
};
const DIGITS = Array.from({ length: 20 }, (_, i) => String(i % 10));
/**
* A number whose digits roll like a mechanical counter. Each column spins
* forward when the value grows and backward when it shrinks, the long way
* round when needed (9 → 0 rolls on to 0, not back through 8…1).
*/
export function Odometer({
value,
decimals = 0,
locale,
prefix = '',
suffix = '',
format,
stagger = 24,
announce = false,
style,
containerStyle,
}: OdometerProps) {
const { colors, font } = useKinetikTheme();
const reduced = useReduceMotion();
const [height, setHeight] = useState(0);
// Roll direction follows the last change (state adjusted during render, per React docs).
const [last, setLast] = useState(value);
const [direction, setDirection] = useState(1);
if (value !== last) {
setLast(value);
setDirection(value > last ? 1 : -1);
}
const formatter = useMemo(
() =>
format ??
((v: number) =>
new Intl.NumberFormat(locale, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(v)),
[format, locale, decimals],
);
const text = `${prefix}${formatter(value)}${suffix}`;
// Key characters by their distance from the right so existing columns keep
// their identity when the number gains or loses digits on the left.
const chars = [...text].map((ch, i, all) => ({ ch, key: `p${all.length - 1 - i}` }));
const textStyle: StyleProp<TextStyle> = [
{
color: colors.text,
fontSize: font.size['3xl'],
fontWeight: font.weight.bold,
fontVariant: ['tabular-nums'],
},
style,
];
const layout = reduced
? undefined
: LinearTransition.springify()
.mass(springs.smooth.mass)
.stiffness(springs.smooth.stiffness)
.damping(springs.smooth.damping);
return (
<View
accessible
accessibilityRole="text"
accessibilityLabel={text}
accessibilityLiveRegion={announce ? 'polite' : 'none'}
style={[styles.row, containerStyle]}
>
{/* Measures one line so columns can clip to it. */}
<Text
style={[textStyle, styles.probe]}
onLayout={(e) => setHeight(e.nativeEvent.layout.height)}
>
8
</Text>
{height > 0 &&
chars.map(({ ch, key }, i) => {
const isDigit = ch >= '0' && ch <= '9';
const column = chars.length - 1 - i;
return (
<Animated.View
key={key + (isDigit ? 'd' : ch)}
layout={layout}
entering={reduced ? undefined : FadeIn.duration(durations.base)}
exiting={reduced ? undefined : FadeOut.duration(durations.quick)}
>
{isDigit ? (
<Column
digit={Number(ch)}
height={height}
direction={direction}
delay={column * stagger}
reduced={reduced}
textStyle={textStyle}
/>
) : (
<Text style={textStyle}>{ch}</Text>
)}
</Animated.View>
);
})}
</View>
);
}
function Column({
digit,
height,
direction,
delay,
reduced,
textStyle,
}: {
digit: number;
height: number;
direction: number;
delay: number;
reduced: boolean;
textStyle: StyleProp<TextStyle>;
}) {
// Position on a 0–19 strip (two copies of 0–9) so a column can roll past 9.
const pos = useSharedValue(digit);
useEffect(() => {
if (reduced) {
pos.set(digit);
return;
}
const mod10 = (n: number) => ((n % 10) + 10) % 10;
// Rebase onto the copy of 0–9 that leaves room to roll in the needed
// direction; both copies look identical, so this jump is invisible.
let from = pos.get();
if (direction > 0 && from >= 10) from -= 10;
if (direction < 0 && from < 10) from += 10;
// Works mid-animation too: `from` may sit between two digits.
const to = direction > 0 ? from + mod10(digit - from) : from - mod10(from - digit);
if (Math.abs(to - from) < 0.001) return;
pos.set(from);
pos.set(
withDelay(
delay,
withSpring(to, spring('smooth'), (finished) => {
'worklet';
if (finished) pos.set(((to % 10) + 10) % 10);
}),
),
);
}, [digit, direction, delay, reduced, pos]);
const strip = useAnimatedStyle(() => ({ transform: [{ translateY: -pos.get() * height }] }));
return (
<View style={{ height, overflow: 'hidden' }}>
{/* Invisible copy of the digit gives the column its width. */}
<Text style={[textStyle, styles.ghost]}>{digit}</Text>
<Animated.View style={[styles.strip, strip]}>
{DIGITS.map((d, i) => (
<Text key={i} style={[textStyle, { height, lineHeight: height }]}>
{d}
</Text>
))}
</Animated.View>
</View>
);
}
const styles = StyleSheet.create({
row: { flexDirection: 'row', alignItems: 'flex-start', alignSelf: 'flex-start' },
probe: { position: 'absolute', opacity: 0 },
ghost: { opacity: 0 },
strip: { position: 'absolute', top: 0, left: 0, right: 0, alignItems: 'center' },
});Installation
npx kinetik-ui add odometerpnpm dlx kinetik-ui add odometerbunx kinetik-ui add odometeryarn dlx kinetik-ui add odometerThe CLI copies the source and installs native dependencies with npx expo install, so versions match your Expo SDK. With shadcn: npx shadcn@latest add https://kinetik-ui.dev/r/odometer.json
Install manually
- Install dependencies
npx expo install react-native-reanimated react-native-worklets - Copy the source files
components/kinetik/odometer.tsx
Usage
import { Odometer } from '@/components/kinetik/odometer';
<Odometer value={revenue} decimals={2} prefix="$" locale="en-US" />;Every digit 0–9 in the formatted value rolls; separators, signs and units stay put. When the value grows the columns roll forward, when it shrinks they roll back, and a column that wraps (9 → 0) keeps rolling instead of reversing through every digit.
When to use
- Counters and totals that change while the user watches: balances, scores, live metrics.
- Moments where the size of a change matters as much as the result.
When not to use
- Values that change many times per second. The digits blur; show a static number or throttle updates.
- Long tables of numbers. Rolling columns draw the eye; keep them for a few headline figures.
Notes
- Pass
formatfor full control; the default usesIntl.NumberFormatwithdecimalsfixed so columns stay aligned. - Columns are keyed from the right, so gaining a digit (999 → 1,000) slides a new column in on the left.
- Use a font with tabular figures (the default sets
fontVariant: ['tabular-nums']) so digits keep a steady width.
Props
OdometerProps
| Prop | Type | Default | Description |
|---|---|---|---|
valuerequired | number | — | |
decimals | number | 0 | Fraction digits shown. Defaults to 0. |
locale | string | — | BCP 47 locale for separators. Defaults to the device locale. |
prefix | string | '' | Text before the number, e.g. a currency sign. |
suffix | string | '' | Text after the number, e.g. a unit. |
format | (value: number) => string | — | Full control over formatting. Digits 0–9 in the result roll; everything else is static. |
stagger | number | 24 | Extra delay per column, right to left, in ms. Defaults to 24. |
announce | boolean | false | Announce changes to screen readers. Defaults to false. |
style | StyleProp<TextStyle> | — | |
containerStyle | StyleProp<ViewStyle> | — |
Performance and accessibility
Performance
- Budget
- One spring per changed digit column on the UI thread; layout transitions only when the number gains or loses columns.
- Measured
Pixel 9 emulator, API 35, release build · interaction median 1.12× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 164 MB app memory. Frames at rest in the measurement came from the demo changing its value every few seconds; with a fixed value nothing redraws.
Real-device measurement pending.
Accessibility
- Reduced motion
- Digits change in place without rolling; no column transitions.
- Screen readers
- Exposed as one text element with the formatted value. Set `announce` to read changes aloud (live region).
- Touch target
- Not interactive.
Try it on a device
Scan with a phone that has the Kinetik playground installed to open this demo with real haptics and sensors.
Source files
components/kinetik/odometer.tsx
import { useEffect, useMemo, useState } from 'react';
import {
StyleSheet,
Text,
View,
type StyleProp,
type TextStyle,
type ViewStyle,
} from 'react-native';
import Animated, {
FadeIn,
FadeOut,
LinearTransition,
useAnimatedStyle,
useSharedValue,
withDelay,
withSpring,
} from 'react-native-reanimated';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { durations, spring, springs } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
export type OdometerProps = {
value: number;
/** Fraction digits shown. Defaults to 0. */
decimals?: number;
/** BCP 47 locale for separators. Defaults to the device locale. */
locale?: string;
/** Text before the number, e.g. a currency sign. */
prefix?: string;
/** Text after the number, e.g. a unit. */
suffix?: string;
/** Full control over formatting. Digits 0–9 in the result roll; everything else is static. */
format?: (value: number) => string;
/** Extra delay per column, right to left, in ms. Defaults to 24. */
stagger?: number;
/** Announce changes to screen readers. Defaults to false. */
announce?: boolean;
style?: StyleProp<TextStyle>;
containerStyle?: StyleProp<ViewStyle>;
};
const DIGITS = Array.from({ length: 20 }, (_, i) => String(i % 10));
/**
* A number whose digits roll like a mechanical counter. Each column spins
* forward when the value grows and backward when it shrinks, the long way
* round when needed (9 → 0 rolls on to 0, not back through 8…1).
*/
export function Odometer({
value,
decimals = 0,
locale,
prefix = '',
suffix = '',
format,
stagger = 24,
announce = false,
style,
containerStyle,
}: OdometerProps) {
const { colors, font } = useKinetikTheme();
const reduced = useReduceMotion();
const [height, setHeight] = useState(0);
// Roll direction follows the last change (state adjusted during render, per React docs).
const [last, setLast] = useState(value);
const [direction, setDirection] = useState(1);
if (value !== last) {
setLast(value);
setDirection(value > last ? 1 : -1);
}
const formatter = useMemo(
() =>
format ??
((v: number) =>
new Intl.NumberFormat(locale, {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(v)),
[format, locale, decimals],
);
const text = `${prefix}${formatter(value)}${suffix}`;
// Key characters by their distance from the right so existing columns keep
// their identity when the number gains or loses digits on the left.
const chars = [...text].map((ch, i, all) => ({ ch, key: `p${all.length - 1 - i}` }));
const textStyle: StyleProp<TextStyle> = [
{
color: colors.text,
fontSize: font.size['3xl'],
fontWeight: font.weight.bold,
fontVariant: ['tabular-nums'],
},
style,
];
const layout = reduced
? undefined
: LinearTransition.springify()
.mass(springs.smooth.mass)
.stiffness(springs.smooth.stiffness)
.damping(springs.smooth.damping);
return (
<View
accessible
accessibilityRole="text"
accessibilityLabel={text}
accessibilityLiveRegion={announce ? 'polite' : 'none'}
style={[styles.row, containerStyle]}
>
{/* Measures one line so columns can clip to it. */}
<Text
style={[textStyle, styles.probe]}
onLayout={(e) => setHeight(e.nativeEvent.layout.height)}
>
8
</Text>
{height > 0 &&
chars.map(({ ch, key }, i) => {
const isDigit = ch >= '0' && ch <= '9';
const column = chars.length - 1 - i;
return (
<Animated.View
key={key + (isDigit ? 'd' : ch)}
layout={layout}
entering={reduced ? undefined : FadeIn.duration(durations.base)}
exiting={reduced ? undefined : FadeOut.duration(durations.quick)}
>
{isDigit ? (
<Column
digit={Number(ch)}
height={height}
direction={direction}
delay={column * stagger}
reduced={reduced}
textStyle={textStyle}
/>
) : (
<Text style={textStyle}>{ch}</Text>
)}
</Animated.View>
);
})}
</View>
);
}
function Column({
digit,
height,
direction,
delay,
reduced,
textStyle,
}: {
digit: number;
height: number;
direction: number;
delay: number;
reduced: boolean;
textStyle: StyleProp<TextStyle>;
}) {
// Position on a 0–19 strip (two copies of 0–9) so a column can roll past 9.
const pos = useSharedValue(digit);
useEffect(() => {
if (reduced) {
pos.set(digit);
return;
}
const mod10 = (n: number) => ((n % 10) + 10) % 10;
// Rebase onto the copy of 0–9 that leaves room to roll in the needed
// direction; both copies look identical, so this jump is invisible.
let from = pos.get();
if (direction > 0 && from >= 10) from -= 10;
if (direction < 0 && from < 10) from += 10;
// Works mid-animation too: `from` may sit between two digits.
const to = direction > 0 ? from + mod10(digit - from) : from - mod10(from - digit);
if (Math.abs(to - from) < 0.001) return;
pos.set(from);
pos.set(
withDelay(
delay,
withSpring(to, spring('smooth'), (finished) => {
'worklet';
if (finished) pos.set(((to % 10) + 10) % 10);
}),
),
);
}, [digit, direction, delay, reduced, pos]);
const strip = useAnimatedStyle(() => ({ transform: [{ translateY: -pos.get() * height }] }));
return (
<View style={{ height, overflow: 'hidden' }}>
{/* Invisible copy of the digit gives the column its width. */}
<Text style={[textStyle, styles.ghost]}>{digit}</Text>
<Animated.View style={[styles.strip, strip]}>
{DIGITS.map((d, i) => (
<Text key={i} style={[textStyle, { height, lineHeight: height }]}>
{d}
</Text>
))}
</Animated.View>
</View>
);
}
const styles = StyleSheet.create({
row: { flexDirection: 'row', alignItems: 'flex-start', alignSelf: 'flex-start' },
probe: { position: 'absolute', opacity: 0 },
ghost: { opacity: 0 },
strip: { position: 'absolute', top: 0, left: 0, right: 0, alignItems: 'center' },
});