Number that tweens between values with reduced-motion support.
components/ui/animated-number/animated-number.tsx@lumioui/core@latestreact-native-reanimated@*Live preview — the real component, driven by the props below.
import { AnimatedNumber } from "@/components/ui/animated-number/animated-number";
export function Example() {
return (
<AnimatedNumber />
);
} import * as React from "react";
import { forwardRef, useEffect, useRef, useState } from "react";
import { Dimensions, Text, type LayoutChangeEvent, type TextProps } from "react-native";
import Animated, {
cancelAnimation,
useAnimatedStyle,
useSharedValue,
withSequence,
withSpring,
withTiming,
runOnJS,
useAnimatedReaction,
} from "react-native-reanimated";
import { cn, useReducedMotion, ease, spring, timing } from "@lumioui/core";
import { Easing } from "react-native-reanimated";
/**
* AnimatedNumber — count-up number that animates from the previous value to
* the new one when `value` changes. Uses tabular-nums for stable digit width.
* <AnimatedNumber value={1234} />
* <AnimatedNumber value={0.85} format={(n) => `${Math.round(n * 100)}%`} />
* With `startOnView` (default) the first count waits until the number is
* actually visible on screen. Reduced motion jumps straight to the value.
* Screen readers hear only the final value — never the mid-count chatter.
*/
/** Re-measure cadence while the first count is still waiting for visibility. */
const VISIBILITY_POLL_MS = 500;
export interface AnimatedNumberProps extends TextProps {
/** Target value. */
value: number;
/** Value the first count starts from. Default 0. */
from?: number;
/** Animation duration in ms. Default 1000. Ignored when `animation` is "spring". */
duration?: number;
/**
* Motion style — "ease" (default) counts up with the ease.out token over
* `duration` ms; "spring" eases in with the spring.roll token instead.
*/
animation?: "ease" | "spring";
/** Format the displayed number. Default rounds and locale-formats. */
format?: (n: number) => string;
/** Wait until the number is visible before the first count. Default true. */
startOnView?: boolean;
className?: string;
}
export const AnimatedNumber = forwardRef<React.ElementRef<typeof Text>, AnimatedNumberProps>(
function AnimatedNumber(
{ value, from = 0, duration = 1000, animation = "ease", format, startOnView = true, className, onLayout, ...props },
ref
) {
const reduce = useReducedMotion();
const fmt = React.useMemo(
() => format ?? ((n: number) => Math.round(n).toLocaleString()),
[format]
);
// Always call the latest format from the worklet without retriggering it.
const fmtRef = useRef(fmt);
fmtRef.current = fmt;
const [text, setText] = useState(() => fmt(from));
const fromRef = useRef(from);
const anim = useSharedValue(from);
// Settle pop — a tiny scale bump when the count lands, so the final
// value "arrives" instead of just stopping. Never fires under reduced
// motion or mid-retarget (the callback checks `finished`).
const pop = useSharedValue(1);
const started = useRef(false);
const hostRef = useRef<React.ElementRef<typeof Text> | null>(null);
// Re-render only when the visible string actually changes (per-frame
// floats would otherwise re-render 60x/sec for the whole duration).
const setTextIfChanged = React.useCallback((next: string) => {
setText((prev) => (prev === next ? prev : next));
}, []);
// Landed: quick inflate then a bouncy settle back to rest. Runs on the
// UI thread as the shared animation callback for either motion style.
const settlePop = React.useCallback(() => {
"worklet";
pop.value = withSequence(
withTiming(1.05, timing.instant),
withSpring(1, spring.bouncy)
);
}, [pop]);
const animateTo = React.useCallback(
(to: number) => {
if (reduce) {
cancelAnimation(anim);
fromRef.current = to;
setText(fmtRef.current(to));
return;
}
anim.value = fromRef.current;
if (animation === "spring") {
// spring.roll token — the odometer-settle spring, reused here so
// the count glides in like a reel instead of a fixed-duration tween.
anim.value = withSpring(to, spring.roll, (finished) => {
if (finished) settlePop();
});
} else {
// ease.out token (bezier [0.16,1,0.3,1]) — wrapped in Easing.bezier
// because Reanimated's withTiming needs an EasingFunction instance.
anim.value = withTiming(
to,
{ duration, easing: Easing.bezier(...ease.out) },
(finished) => {
if (finished) settlePop();
}
);
}
fromRef.current = to;
},
[anim, duration, animation, reduce, settlePop]
);
const start = React.useCallback(() => {
if (started.current) return;
started.current = true;
animateTo(value);
}, [animateTo, value]);
// First count: immediately, or once visible. measureInWindow is async
// and can report garbage (0 height, NaN on web) before first layout —
// untrusted readings are skipped, not treated as "off screen".
const checkVisible = React.useCallback(() => {
if (started.current) return;
if (!startOnView) {
start();
return;
}
const node = hostRef.current as unknown as {
measureInWindow?: (cb: (x: number, y: number, w: number, h: number) => void) => void;
} | null;
if (!node?.measureInWindow) {
start();
return;
}
try {
node.measureInWindow((_x, y, _w, h) => {
if (started.current) return;
if (!Number.isFinite(y) || !Number.isFinite(h) || h <= 0) return;
const screenH = Dimensions.get("window").height;
if (y + h > 0 && y < screenH) start();
});
} catch {
start();
}
}, [start, startOnView]);
useEffect(() => {
// Catch the already-on-screen case at mount, then keep re-measuring
// until the first count starts — covers mount-time layout races and
// scroll-into-view. The poll clears itself on start and on unmount,
// so nothing leaks.
checkVisible();
if (!startOnView) return;
const id = setInterval(() => {
if (started.current) {
clearInterval(id);
return;
}
checkVisible();
}, VISIBILITY_POLL_MS);
return () => clearInterval(id);
}, [checkVisible, startOnView]);
// Later value changes always animate (covers lists that scrolled into
// view without a fresh layout pass). Skipped on mount — the mount
// effect above owns the first count so startOnView is honored.
const mounted = useRef(false);
useEffect(() => {
if (!mounted.current) {
mounted.current = true;
return;
}
if (!started.current) {
started.current = true;
}
animateTo(value);
return () => cancelAnimation(anim);
}, [value, animateTo, anim]);
// React to the shared value and update the displayed text.
useAnimatedReaction(
() => anim.value,
(v) => {
runOnJS(setTextIfChanged)(fmtRef.current(v));
},
[setTextIfChanged]
);
const popStyle = useAnimatedStyle(() => ({
transform: [{ scale: pop.value }],
}));
return (
<Animated.Text
style={popStyle}
ref={(node) => {
const textNode = node as unknown as React.ElementRef<typeof Text> | null;
hostRef.current = textNode;
if (typeof ref === "function") ref(textNode as React.ElementRef<typeof Text>);
else if (ref) ref.current = textNode;
}}
onLayout={(e: LayoutChangeEvent) => {
checkVisible();
onLayout?.(e);
}}
className={cn("font-mono tabular-nums text-foreground", className)}
accessibilityRole="text"
accessibilityLabel={fmt(value)}
accessibilityLiveRegion="none"
{...props}
>
{text}
</Animated.Text>
);
}
);
AnimatedNumber.displayName = "AnimatedNumber";
| value | number | Target value. | |
| from? | number | Value the first count starts from. Default 0. | |
| duration? | number | Animation duration in ms. Default 1000. Ignored when `animation` is "spring". | |
| animation? | "ease" | "spring" | Motion style — "ease" (default) counts up with the ease.out token over `duration` ms; "spring" eases in with the spring.roll token instead. | |
| format? | (n: number) => string | Format the displayed number. Default rounds and locale-formats. | |
| startOnView? | boolean | Wait until the number is visible before the first count. Default true. | |
| className? | string |
Number that tweens between values with reduced-motion support.
import { AnimatedBalance } from "@/components/ui/animated-number/variants/balance";
export function Example() {
return (
<AnimatedBalance />
);
} import * as React from "react";
import { View } from "react-native";
import { AnimatedNumber } from "../animated-number";
import { Badge } from "../../badge/badge";
import { Button } from "../../button/button";
import { Card } from "../../card/card";
import { Text } from "../../text/text";
/** AnimatedBalance — live balance card with sparkline and count-up ticks. */
export function AnimatedBalance() {
const [balance, setBalance] = React.useState(24806);
const [delta, setDelta] = React.useState(1240);
const [spark, setSpark] = React.useState([42, 55, 48, 61, 58, 70, 66, 78, 74, 86, 82, 95]);
const [runId, setRunId] = React.useState(0);
React.useEffect(() => {
const id = setInterval(() => {
const move = Math.round((Math.random() - 0.42) * 640);
setBalance((v) => Math.max(0, v + move));
setDelta(move);
setSpark((s) => [...s.slice(1), Math.max(20, (s[s.length - 1] ?? 50) + Math.round(move / 8))]);
}, 2600);
return () => clearInterval(id);
}, []);
const max = Math.max(...spark);
const up = delta >= 0;
return (
<View className="w-full gap-3">
<Card variant="glass" className="w-full">
<Card.Content className="pt-5">
<View className="flex-row items-center justify-between">
<Text variant="small">Total balance</Text>
<Badge variant={up ? "success" : "destructive"} icon={up ? "arrow-up" : "arrow-down"}>
{`${up ? "+" : "-"}$${Math.abs(Math.round(delta)).toLocaleString()} today`}
</Badge>
</View>
<Text variant="h1">
<AnimatedNumber
key={runId}
value={balance}
format={(n: number) => `$${Math.round(n).toLocaleString()}`}
/>
</Text>
<View className="h-12 flex-row items-end gap-1 pt-2" accessibilityElementsHidden>
{spark.map((h, i) => (
<View
key={`${i}-${h}`}
className={i === spark.length - 1 ? "flex-1 rounded-t bg-primary" : "flex-1 rounded-t bg-primary/35"}
style={{ height: `${Math.max(8, Math.round((h / max) * 100))}%` }}
/>
))}
</View>
<View className="flex-row gap-2 pt-3">
<Button size="sm" className="flex-1" onPress={() => setBalance((v) => v + 500)}>+$500</Button>
<Button size="sm" variant="outline" className="flex-1" onPress={() => setBalance((v) => Math.max(0, v - 200))}>-$200</Button>
<Button size="sm" variant="ghost" onPress={() => setRunId((k) => k + 1)}>Replay</Button>
</View>
</Card.Content>
</Card>
</View>
);
}
Number that tweens between values with reduced-motion support.
import { AnimatedStats } from "@/components/ui/animated-number/variants/stats";
export function Example() {
return (
<AnimatedStats />
);
} import * as React from "react";
import { View } from "react-native";
import { AnimatedNumber } from "../animated-number";
import { Text } from "../../text/text";
import { Glass } from "../../glass/glass";
/** AnimatedStats — compact stat tiles with formatted count-ups. */
export function AnimatedStats() {
return (
<View className="w-full gap-3">
<View className="flex-row gap-2 w-full">
<Glass interactive curvature="edge" className="flex-1 gap-0.5 rounded-2xl p-3">
<Text variant="small">Orders</Text>
<Text variant="large">
<AnimatedNumber value={1284} format={(n: number) => Intl.NumberFormat("en", { notation: "compact" }).format(Math.round(n))} />
</Text>
</Glass>
<Glass interactive curvature="edge" className="flex-1 gap-0.5 rounded-2xl p-3">
<Text variant="small">Uptime</Text>
<Text variant="large">
<AnimatedNumber value={99.99} format={(n: number) => `${n.toFixed(2)}%`} />
</Text>
</Glass>
<Glass interactive curvature="edge" className="flex-1 gap-0.5 rounded-2xl p-3">
<Text variant="small">Rating</Text>
<Text variant="large">
<AnimatedNumber value={4.8} format={(n: number) => n.toFixed(1)} />
</Text>
</Glass>
</View>
</View>
);
}