Odometer-style animated number — each digit rolls through a 0–9 column with a spring when the value changes. For balances, stats, and counts.
components/ui/ticker/ticker.tsx@lumioui/core@latestreact-native-reanimated@^4.0.0Live preview — the real component, driven by the props below.
import { Ticker } from "@/components/ui/ticker/ticker";
export function Example() {
return (
<Ticker />
);
} import * as React from "react";
import { forwardRef } from "react";
import { Text, View, type ViewProps } from "react-native";
import Animated, {
useSharedValue,
useAnimatedStyle,
withDelay,
withSpring,
runOnJS,
} from "react-native-reanimated";
import { cn, spring, useHaptic, useReducedMotion } from "@lumioui/core";
/**
* Ticker — PRO component. Odometer-style animated number: each digit is a
* column of 0–9 that rolls to the current value with a spring. Non-digit
* characters (`.`, `,`, `-`, currency symbols) render statically.
*
* ```tsx
* <Ticker value={balance} prefix="$" decimals={2} />
* ```
* `tickHaptic` opts in to a selection haptic as each column settles —
* leave it off for frequently-changing values to avoid haptic spam.
*/
export interface TickerProps extends ViewProps {
/** The number to display. Rolls when it changes. */
value: number;
/** Decimal places. Default 0. */
decimals?: number;
/** Thousands separators. Default true. */
grouped?: boolean;
/** Static prefix (e.g. "$", "+"). */
prefix?: string;
/** Static suffix (e.g. "%", "ms"). */
suffix?: string;
/** Text classes — controls size/weight/color of every digit. */
textClassName?: string;
/** Digit cell height in pt. Default 28 (matches text-xl). */
digitHeight?: number;
/** Selection haptic as each column settles. Default false (opt-in). */
tickHaptic?: boolean;
className?: string;
}
const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
/** Digit cascade — each column lags the one to its right, so rolls ripple
* from least- to most-significant digit like a real counter carrying. */
const CASCADE_STEP_MS = 45;
function DigitColumn({
digit,
height,
delay,
textClassName,
onSettle,
}: {
digit: number;
height: number;
/** ms this column waits before rolling — the carry cascade. */
delay: number;
textClassName?: string;
/** Fires on the JS thread when this column's roll finishes. */
onSettle?: () => void;
}) {
const reduceMotion = useReducedMotion();
const roll = useSharedValue(digit);
React.useEffect(() => {
// The shared value is the resting position — a same-digit render (and
// the mount pass) is a no-op, so tickHaptic can't fire on first paint.
if (roll.value === digit) return;
// spring.roll token — soft odometer spring so digits coast and settle
// like a mechanical reel. Reduced motion: snap straight to the digit.
roll.value = reduceMotion
? digit
: withDelay(
delay,
withSpring(digit, spring.roll, (finished) => {
if (finished && onSettle) runOnJS(onSettle)();
})
);
}, [digit, delay, roll, reduceMotion, onSettle]);
const style = useAnimatedStyle(() => ({
transform: [{ translateY: -roll.value * height }],
}));
return (
// Hidden on both platforms so screen readers announce the composed
// value once via the outer label — never the rolling intermediates.
<View
style={{ height, overflow: "hidden" }}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
<Animated.View style={style}>
{DIGITS.map((d) => (
<Text
key={d}
className={cn("text-center font-semibold tabular-nums text-foreground", textClassName)}
style={{ height, lineHeight: height }}
// Fixed metrics keep the drum geometry stable while digits roll.
allowFontScaling={false}
>
{d}
</Text>
))}
</Animated.View>
</View>
);
}
export const Ticker = forwardRef<View, TickerProps>(function Ticker(
{
value,
decimals = 0,
grouped = true,
prefix,
suffix,
textClassName,
digitHeight = 28,
tickHaptic = false,
className,
...props
}: TickerProps,
ref
) {
const haptics = useHaptic();
const formatted = Math.abs(value).toLocaleString("en-US", {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
useGrouping: grouped,
});
const chars = `${value < 0 ? "-" : ""}${prefix ?? ""}${formatted}${suffix ?? ""}`;
// Digit positions from the right — the least-significant column rolls first.
const digitCount = chars.split("").filter((ch) => /\d/.test(ch)).length;
let digitOrder = digitCount;
return (
<View
ref={ref}
className={cn("flex-row items-baseline", className)}
accessible
accessibilityRole="text"
accessibilityLabel={chars}
// Settled value announces politely; the rolling intermediates stay hidden.
accessibilityLiveRegion="polite"
{...props}
>
{chars.split("").map((ch, i) => {
if (/\d/.test(ch)) {
digitOrder -= 1;
return (
<DigitColumn
key={i}
digit={Number(ch)}
height={digitHeight}
delay={digitOrder * CASCADE_STEP_MS}
textClassName={textClassName}
onSettle={tickHaptic ? haptics.selection : undefined}
/>
);
}
return (
<Text
key={i}
className={cn("font-semibold tabular-nums text-foreground", textClassName)}
style={{ height: digitHeight, lineHeight: digitHeight }}
// Static punctuation joins the composed announcement; hidden
// individually so it isn't read apart from the value.
accessibilityElementsHidden
importantForAccessibility="no"
allowFontScaling={false}
>
{ch}
</Text>
);
})}
</View>
);
});
Ticker.displayName = "Ticker";
| value | number | The number to display. Rolls when it changes. | |
| decimals? | number | Decimal places. Default 0. | |
| grouped? | boolean | Thousands separators. Default true. | |
| prefix? | string | Static prefix (e.g. "$", "+"). | |
| suffix? | string | Static suffix (e.g. "%", "ms"). | |
| textClassName? | string | Text classes — controls size/weight/color of every digit. | |
| digitHeight? | number | Digit cell height in pt. Default 28 (matches text-xl). | |
| tickHaptic? | boolean | Selection haptic as each column settles. Default false (opt-in). | |
| className? | string |
Odometer-style animated number — each digit rolls through a 0–9 column with a spring when the value changes. For balances, stats, and counts.
import { Ticker } from "@/components/ui/ticker/ticker";
export function Example() {
return (
<Ticker />
);
} import * as React from "react";
import { forwardRef } from "react";
import { Text, View, type ViewProps } from "react-native";
import Animated, {
useSharedValue,
useAnimatedStyle,
withDelay,
withSpring,
runOnJS,
} from "react-native-reanimated";
import { cn, spring, useHaptic, useReducedMotion } from "@lumioui/core";
/**
* Ticker — PRO component. Odometer-style animated number: each digit is a
* column of 0–9 that rolls to the current value with a spring. Non-digit
* characters (`.`, `,`, `-`, currency symbols) render statically.
*
* ```tsx
* <Ticker value={balance} prefix="$" decimals={2} />
* ```
* `tickHaptic` opts in to a selection haptic as each column settles —
* leave it off for frequently-changing values to avoid haptic spam.
*/
export interface TickerProps extends ViewProps {
/** The number to display. Rolls when it changes. */
value: number;
/** Decimal places. Default 0. */
decimals?: number;
/** Thousands separators. Default true. */
grouped?: boolean;
/** Static prefix (e.g. "$", "+"). */
prefix?: string;
/** Static suffix (e.g. "%", "ms"). */
suffix?: string;
/** Text classes — controls size/weight/color of every digit. */
textClassName?: string;
/** Digit cell height in pt. Default 28 (matches text-xl). */
digitHeight?: number;
/** Selection haptic as each column settles. Default false (opt-in). */
tickHaptic?: boolean;
className?: string;
}
const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
/** Digit cascade — each column lags the one to its right, so rolls ripple
* from least- to most-significant digit like a real counter carrying. */
const CASCADE_STEP_MS = 45;
function DigitColumn({
digit,
height,
delay,
textClassName,
onSettle,
}: {
digit: number;
height: number;
/** ms this column waits before rolling — the carry cascade. */
delay: number;
textClassName?: string;
/** Fires on the JS thread when this column's roll finishes. */
onSettle?: () => void;
}) {
const reduceMotion = useReducedMotion();
const roll = useSharedValue(digit);
React.useEffect(() => {
// The shared value is the resting position — a same-digit render (and
// the mount pass) is a no-op, so tickHaptic can't fire on first paint.
if (roll.value === digit) return;
// spring.roll token — soft odometer spring so digits coast and settle
// like a mechanical reel. Reduced motion: snap straight to the digit.
roll.value = reduceMotion
? digit
: withDelay(
delay,
withSpring(digit, spring.roll, (finished) => {
if (finished && onSettle) runOnJS(onSettle)();
})
);
}, [digit, delay, roll, reduceMotion, onSettle]);
const style = useAnimatedStyle(() => ({
transform: [{ translateY: -roll.value * height }],
}));
return (
// Hidden on both platforms so screen readers announce the composed
// value once via the outer label — never the rolling intermediates.
<View
style={{ height, overflow: "hidden" }}
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
<Animated.View style={style}>
{DIGITS.map((d) => (
<Text
key={d}
className={cn("text-center font-semibold tabular-nums text-foreground", textClassName)}
style={{ height, lineHeight: height }}
// Fixed metrics keep the drum geometry stable while digits roll.
allowFontScaling={false}
>
{d}
</Text>
))}
</Animated.View>
</View>
);
}
export const Ticker = forwardRef<View, TickerProps>(function Ticker(
{
value,
decimals = 0,
grouped = true,
prefix,
suffix,
textClassName,
digitHeight = 28,
tickHaptic = false,
className,
...props
}: TickerProps,
ref
) {
const haptics = useHaptic();
const formatted = Math.abs(value).toLocaleString("en-US", {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
useGrouping: grouped,
});
const chars = `${value < 0 ? "-" : ""}${prefix ?? ""}${formatted}${suffix ?? ""}`;
// Digit positions from the right — the least-significant column rolls first.
const digitCount = chars.split("").filter((ch) => /\d/.test(ch)).length;
let digitOrder = digitCount;
return (
<View
ref={ref}
className={cn("flex-row items-baseline", className)}
accessible
accessibilityRole="text"
accessibilityLabel={chars}
// Settled value announces politely; the rolling intermediates stay hidden.
accessibilityLiveRegion="polite"
{...props}
>
{chars.split("").map((ch, i) => {
if (/\d/.test(ch)) {
digitOrder -= 1;
return (
<DigitColumn
key={i}
digit={Number(ch)}
height={digitHeight}
delay={digitOrder * CASCADE_STEP_MS}
textClassName={textClassName}
onSettle={tickHaptic ? haptics.selection : undefined}
/>
);
}
return (
<Text
key={i}
className={cn("font-semibold tabular-nums text-foreground", textClassName)}
style={{ height: digitHeight, lineHeight: digitHeight }}
// Static punctuation joins the composed announcement; hidden
// individually so it isn't read apart from the value.
accessibilityElementsHidden
importantForAccessibility="no"
allowFontScaling={false}
>
{ch}
</Text>
);
})}
</View>
);
});
Ticker.displayName = "Ticker";