Native, high-fidelity numeric text transitions for React Native — SwiftUI's real numericText on iOS, and a renderer measured against it, digit for digit, on Android.
NumericText is for counters, balances, prices, scores, timers, and any interface where numbers change often enough that the motion becomes part of the product. Each digit column rolls on its own spring, structural changes (999 → 1,000) stay coherent, and rapid updates keep their motion instead of restarting.
Google Pixel 9 Pro · Android · click to play the demo
On iOS 17+ the component uses SwiftUI's own .contentTransition(.numericText()). On Android it uses a dedicated native renderer built for the same interaction class — and tuned against the iOS output frame by frame (see How it works).
Targets React Native's New Architecture (Fabric).
Animating a number well is not the same problem as fading one string into another. A formatted number has structure: digits persist while their neighbours change, separators appear and disappear, fraction digits are anchored differently from integer digits, and a new value can arrive before the previous animation has settled.
9 → 10
999 → 1,000
9.99 → 10.00
12,499 → 12,500
react-native-numeric-text treats these as one transition system rather than a pile of isolated effects, natively on both platforms.
- Native rendering on iOS and Android — no JS-driven per-digit animation.
- SwiftUI
.contentTransition(.numericText())on iOS 17+; a measured native renderer on Android. - Stable interruption and rapid-retarget behaviour; continuous increment/decrement without restarting.
- Structural handling of integer digits, fraction digits, grouping/decimal separators, and signs.
- Locale-aware native formatting via an
Intl.NumberFormat-shapedformatprop. - Currency (symbol or ISO code, accounting negatives) and percent, resolved natively.
- Identical rounding across iOS, Android, and web.
- Optional Reanimated shared value as the
value, driven from the UI thread. - Two-colour amounts (
fractionColor) and left / center / right alignment that holds through the animation. - System-aware reduced-motion support.
npm install react-native-numeric-textyarn add react-native-numeric-textThe package autolinks. On iOS, install pods after adding the dependency:
cd ios && pod installreact-native-reanimated (>= 3) is an optional peer dependency, needed only to pass a shared value as value. Nothing imports it otherwise, so apps that pass plain numbers do not need it installed.
import { NumericText } from 'react-native-numeric-text';
export function Balance({ value }: { value: number }) {
return (
<NumericText
value={value}
style={{ fontSize: 48, fontWeight: '700', color: '#111111' }}
/>
);
}The first value renders immediately; later value changes transition natively.
format is a subset of Intl.NumberFormatOptions, resolved by each platform's own formatter (NumberFormatter on iOS, android.icu on Android, Intl on web). The number is produced where it is drawn, so the renderer animates the structure of a formatted number rather than a string handed to it.
<NumericText
value={1234.5}
locale="de-DE"
format={{ useGrouping: true, minimumFractionDigits: 2, maximumFractionDigits: 2 }}
/> // 1.234,50
formatis an object — hoist it to module scope or wrap it inuseMemoif you rely on the component skipping re-renders.
<NumericText value={1234.5} currency="USD" /> // $1,234.50
<NumericText value={1234.5} currency="JPY" /> // ¥1,235
<NumericText value={1234.5} locale="de-DE" currency="EUR" /> // 1.234,50 €
<NumericText value={0.42} format={{ style: 'percent' }} /> // 42%currency is shorthand for format={{ style: 'currency', currency }}. The affix takes part in the transition — keyed by its distance from the digits, so $999 → $1,000 slides one $ left instead of destroying and recreating it. Fraction digits follow the currency when unset (2 for USD, 0 for JPY, 3 for BHD).
fractionColor draws the fraction span — the decimal separator, the digits after it, and any trailing affix — in a second colour, so the figure that matters reads first. The fraction rolls together with the rest of the digits.
<NumericText
value={balance}
currency="USD"
style={{ fontSize: 50, color: '#FFFFFF' }}
fractionColor="#8A9BA8"
/> // "$1,234" white, ".56" greyBy default a number grows and shrinks about its centre. Set textAlign in style to pin an edge instead — the aligned edge then stays fixed for the whole transition, incoming and outgoing, so a left-aligned counter never drifts sideways as it changes width.
<NumericText value={value} style={{ fontSize: 48, textAlign: 'left' }} />Only 'left' | 'center' | 'right' are honoured; 'auto' and 'justify' fall through to the default (center).
<NumericText value={score} direction="up" /> // 'automatic' | 'up' | 'down'
<NumericText value={count} reduceMotion="system" /> // 'system' | 'always' | 'never'direction defaults to 'automatic' (rolls up when the value grows, down when it shrinks). reduceMotion 'system' follows the OS accessibility setting, 'always' disables the transition, 'never' keeps it regardless.
value also takes a Reanimated shared value. The number it carries is written into the native view from the UI thread, so a value driven by a gesture, a spring, or a timing reaches the renderer without crossing to JavaScript and without re-rendering anything.
import { Pressable } from 'react-native';
import { NumericText } from 'react-native-numeric-text';
import { useSharedValue, withTiming } from 'react-native-reanimated';
export function Balance() {
const amount = useSharedValue(1240.5);
return (
<Pressable onPress={() => (amount.value = withTiming(0, { duration: 2600 }))}>
<NumericText value={amount} currency="USD" style={{ fontSize: 48 }} />
</Pressable>
);
}A DerivedValue works the same way. A plain number and a shared value are rendered by different components, so pick one per component for its lifetime — switching remounts the native view. On platforms without the native renderer the shared value is mirrored into React state and drawn as static text.
| Prop | Type | Default | Description |
|---|---|---|---|
value |
number | SharedValue<number> |
required | Number to display. The first render does not animate. A shared value is driven from the UI thread. |
locale |
string |
'en-US' |
BCP-47 locale for native formatting. Defaults to en-US rather than the device locale so layout does not change with the device language. |
format |
NumericTextFormat |
{} |
Shape of the number. See below. |
currency |
string |
— | Shorthand for format={{ style: 'currency', currency }}. format wins where they overlap. |
direction |
'automatic' | 'up' | 'down' |
'automatic' |
Direction of the roll. |
animationDuration |
number |
320 |
Android only. Nominal spring duration in ms; scales the transition (floored at 80). Ignored on iOS, whose transition has its own fixed timing. |
reduceMotion |
'system' | 'always' | 'never' |
'system' |
Motion accessibility behaviour. |
useGrouping |
boolean |
true |
Shorthand for the same field of format. |
minimumFractionDigits |
number |
— | Shorthand for the same field of format. |
maximumFractionDigits |
number |
— | Shorthand for the same field of format. |
fractionColor |
ColorValue |
— | Second colour for the fraction span. |
style |
StyleProp<TextStyle> |
— | fontSize, fontWeight, fontFamily, color, and textAlign (left/center/right) are forwarded to the native renderer; the rest applies to the view. Native defaults: size 48, colour black, alignment center. |
testID |
string |
— | Test identifier. |
Plus the standard React Native accessibility props.
A subset of Intl.NumberFormatOptions, resolved by each platform's own formatter.
| Option | Type | Default | Description |
|---|---|---|---|
style |
'decimal' | 'currency' | 'percent' |
'decimal' |
'currency' needs currency; 'percent' multiplies by 100. |
currency |
string |
— | ISO 4217 code. |
currencyDisplay |
'symbol' | 'code' |
'symbol' |
$1,234.56 or USD 1,234.56. |
currencySign |
'standard' | 'accounting' |
'standard' |
'accounting' brackets negatives; applies with currencyDisplay: 'symbol'. |
useGrouping |
boolean |
true |
Grouping separators. |
minimumIntegerDigits |
number |
— | Pads with leading zeros to this width. |
minimumFractionDigits |
number |
style's own | 0 decimal/percent; the currency's count for money. |
maximumFractionDigits |
number |
style's own | 3 decimal, 0 percent; the currency's count for money. |
minimumSignificantDigits |
number |
— | Takes precedence over the fraction bounds. |
maximumSignificantDigits |
number |
— | Takes precedence over the fraction bounds. |
Rounding is fixed at half-away-from-zero on both platforms and web (Intl's default) so the two renderers never disagree on the number they draw. Intl options that are intentionally not supported — currencyDisplay: 'name'/'narrowSymbol', notation: 'compact', signDisplay, unit, roundingMode — and the reasons are documented in docs/ARCHITECTURE.md.
| Platform | Behaviour |
|---|---|
| iOS 17+ | SwiftUI .contentTransition(.numericText()), formatted by NumberFormatter. |
| Earlier iOS | Native formatting and rendering, without the unavailable numeric transition. |
| Android API 31+ | Native renderer with RenderNode + RenderEffect blur, formatted by android.icu. |
| Android API 24–30 | Same transition model with a software-layer blur path while animating. |
| Web | Correctly formatted static text via Intl; the transition is not animated. |
Minimum Android SDK is 24.
The Android renderer is not a grid of independent digit views. It typesets the full formatted line, keeps immutable value rasters, gives every digit and separator a structural identity (integer digits anchored from the left, fraction digits from the decimal mark, affixes keyed by distance from the digits), and evaluates the motion analytically per frame so continuous updates stay cheap.
Its parameters are not taste — they were measured against SwiftUI's output. The iOS transition was recorded frame by frame and decomposed per glyph into offset, scale, blur, and opacity; Apple's constants (a one-sided entry offset of 0.59375 glyph heights, a birth/death scale of 0.3984375, two separate clocks for position vs size/opacity, a fixed 0.15 s left-to-right cascade) fell out of the fit and are what the Android engine reproduces.
Two documents cover this in full:
- docs/ARCHITECTURE.md — the renderer's design and the measured transition model.
- docs/METHODOLOGY.md — how the model was reverse-engineered from a closed-source animation: the measurement techniques, the iteration protocol, and the assumptions the data falsified.
The raw derivation — every ground-truth recording, the measurement scripts, and the iteration-by-iteration log — is preserved on the research/engine-derivation branch rather than carried on main.
The example/ app is a public showcase and a development harness; it is excluded from the published npm package. example/src/SharedValueLab.tsx drives the component from a shared value and prints React's render count beside it — the number rolls, the count does not move.
- The formatting API — one
Intl-shapedformatobject — is borrowed fromnumber-flowby Maxwell Barvian. - Currency and percent formatting began as a contribution by @Amanfromearth in #4.
- The original spark was Nathan Schroeder's Expo UI demo of SwiftUI
numericTexton iOS.
Independent project; not affiliated with Expo, Apple, or number-flow.
MIT. The bundled Android numeric typeface (a subset of Sunghyun Sans) is distributed under the SIL Open Font License 1.1; see android/src/main/assets/fonts/OFL.txt.
