Skip to content
Native Animation

React Native Animated Counter with Rolling Digits

Updated

A React Native animated counter can create a rolling-digit effect by clipping an outgoing and incoming value, then translating both from one shared Reanimated progress value. This tutorial builds an interruptible counter for Expo SDK 57 and React Native 0.86, with direction-aware motion, color interpolation, accessible controls, and no React state updates per animation frame.

What you'll build

The result displays a large value with increase and decrease buttons. When the value changes, the old number rolls out and the new number rolls in; the same progress value shifts the surface toward a cool or warm tint and gives the number a small scale response.

The tutorial animates the complete formatted number as one row, which keeps the code understandable and handles signs or units cleanly. A production odometer can split the string into independently clipped character slots so only changed digits move.

Prerequisites

Use Expo SDK 57, React Native 0.86, Reanimated 4.5.1, react-native-worklets 0.10.1, and expo-linear-gradient. Gesture Handler ~2.32, Skia 2.6.2, Blur, and Haptics are not needed for this button-driven version.

Install the SDK-compatible dependencies:

npx expo install react-native-reanimated react-native-worklets expo-linear-gradient

Read the official Reanimated withTiming documentation for animation configuration and the Expo SDK 57 Reanimated page for project setup.

Separate semantic state from visual progress

React owns the current value because it affects application logic, labels, and callbacks. Reanimated owns only the transition phase. At progress zero, the incoming value is one line-height away; at one, it is centered and the outgoing value has left the clipped window.

The important ordering detail is to commit the new strings first and reset progress in useLayoutEffect. If progress resets before React renders the new incoming value, a stale digit can flash for one frame. The real catalog component uses the same principle for independently rolling character slots.

Build the rolling counter

Create RollingCounter.tsx:

import { useLayoutEffect, useState } from 'react';
import {
  Pressable,
  StyleSheet,
  Text,
  View,
  type AccessibilityActionEvent,
} from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, {
  Easing,
  interpolate,
  interpolateColor,
  useAnimatedStyle,
  useReducedMotion,
  useSharedValue,
  withTiming,
} from 'react-native-reanimated';

type Direction = 1 | -1;

type RollingCounterProps = {
  initialValue?: number;
  min?: number;
  max?: number;
  step?: number;
  unit?: string;
  onChange?: (value: number) => void;
};

const ROW_HEIGHT = 82;

export function RollingCounter({
  initialValue = 70,
  min = 60,
  max = 85,
  step = 1,
  unit = '°',
  onChange,
}: RollingCounterProps) {
  const [value, setValue] = useState(initialValue);
  const [previous, setPrevious] = useState(initialValue);
  const [direction, setDirection] = useState<Direction>(1);
  const reducedMotion = useReducedMotion();
  const progress = useSharedValue(1);

  function changeBy(delta: number) {
    const next = Math.min(max, Math.max(min, value + delta));
    if (next === value) return;

    setPrevious(value);
    setDirection(next > value ? 1 : -1);
    setValue(next);
    onChange?.(next);
  }

  useLayoutEffect(() => {
    if (previous === value) return;
    progress.set(0);
    progress.set(
      withTiming(1, {
        duration: reducedMotion ? 120 : 260,
        easing: reducedMotion
          ? Easing.linear
          : Easing.bezier(0.2, 0.8, 0.2, 1),
      }),
    );
  }, [previous, progress, reducedMotion, value]);

  const outgoingStyle = useAnimatedStyle(() => ({
    opacity: reducedMotion ? 1 - progress.get() : 1,
    transform: [
      {
        translateY: reducedMotion
          ? 0
          : -direction * progress.get() * ROW_HEIGHT,
      },
    ],
  }));

  const incomingStyle = useAnimatedStyle(() => ({
    opacity: reducedMotion ? progress.get() : 1,
    transform: [
      {
        translateY: reducedMotion
          ? 0
          : direction * (1 - progress.get()) * ROW_HEIGHT,
      },
      {
        scale: interpolate(progress.get(), [0, 0.75, 1], [0.98, 1.02, 1]),
      },
    ],
  }));

  const surfaceStyle = useAnimatedStyle(() => ({
    backgroundColor: interpolateColor(
      progress.get(),
      [0, 1],
      direction > 0 ? ['#FFF2EE', '#FFFFFF'] : ['#EEF6FF', '#FFFFFF'],
    ),
  }));

  function handleAccessibilityAction(event: AccessibilityActionEvent) {
    if (event.nativeEvent.actionName === 'increment') changeBy(step);
    if (event.nativeEvent.actionName === 'decrement') changeBy(-step);
  }

  return (
    <Animated.View
      accessibilityActions={[
        { name: 'increment', label: 'Increase value' },
        { name: 'decrement', label: 'Decrease value' },
      ]}
      accessibilityLabel="Temperature"
      accessibilityRole="adjustable"
      accessibilityValue={{
        min,
        max,
        now: value,
        text: String(value) + unit,
      }}
      onAccessibilityAction={handleAccessibilityAction}
      style={[styles.card, surfaceStyle]}
    >
      <LinearGradient
        colors={['rgba(255,255,255,0.82)', 'rgba(255,255,255,0.16)']}
        pointerEvents="none"
        style={StyleSheet.absoluteFill}
      />

      <Pressable
        accessibilityLabel="Increase temperature"
        accessibilityRole="button"
        disabled={value >= max}
        onPress={() => changeBy(step)}
        style={styles.control}
      >
        <Text style={styles.controlText}>+</Text>
      </Pressable>

      <View
        accessibilityElementsHidden
        importantForAccessibility="no-hide-descendants"
        style={styles.window}
      >
        <Animated.Text style={[styles.number, outgoingStyle]}>
          {previous}{unit}
        </Animated.Text>
        <Animated.Text style={[styles.number, incomingStyle]}>
          {value}{unit}
        </Animated.Text>
      </View>

      <Pressable
        accessibilityLabel="Decrease temperature"
        accessibilityRole="button"
        disabled={value <= min}
        onPress={() => changeBy(-step)}
        style={styles.control}
      >
        <Text style={styles.controlText}>−</Text>
      </Pressable>
    </Animated.View>
  );
}

const styles = StyleSheet.create({
  card: {
    alignItems: 'center',
    alignSelf: 'center',
    borderColor: '#FFFFFF',
    borderRadius: 34,
    borderWidth: 1,
    boxShadow: '0px 18px 50px rgba(30,35,52,0.14)',
    gap: 18,
    overflow: 'hidden',
    padding: 24,
    width: 240,
  },
  window: { height: ROW_HEIGHT, overflow: 'hidden', width: 190 },
  number: {
    color: '#101116',
    fontSize: 64,
    fontVariant: ['tabular-nums'],
    height: ROW_HEIGHT,
    lineHeight: ROW_HEIGHT,
    position: 'absolute',
    textAlign: 'center',
    width: '100%',
  },
  control: {
    alignItems: 'center',
    borderRadius: 26,
    justifyContent: 'center',
    minHeight: 52,
    minWidth: 52,
  },
  controlText: { color: '#35313F', fontSize: 30 },
});

Make rapid changes interruptible

Reanimated animations on a shared value are interruptible: setting a new animation replaces the current one from its current visual position. The state transition around the strings needs the same care. The example captures the currently committed value as previous, commits the next value, and restarts the phase after layout.

For very rapid taps, a whole-number roller may replace an in-flight outgoing value abruptly because React has only two rendered strings. A production per-digit implementation can calculate a new start phase from progress.get(), keep stable character slots, and preserve visual continuity when direction reverses. That is especially important for hold-to-repeat controls.

Do not use a long spring for every digit. Counters communicate information first, so 200–300 ms is usually enough. Use tabular numbers to prevent glyph-width changes from nudging the layout sideways.

Roll only the digits that changed

For an odometer-like component, format both values to equal-length strings, pair characters by position, and render one clipped window per character. Unchanged characters stay still; changed characters each derive their transform from the same progress shared value. Keep punctuation and the unit in fixed slots.

This preserves the single-timeline model while improving legibility. If 79 becomes 80, both digits can roll; if 70 becomes 71, only the final digit moves. The catalog version also adapts the number of slots and uses a short-lived motion echo to suggest speed without blurring the text itself.

Avoid deriving display text inside a worklet. Formatting belongs in JavaScript when the semantic value changes, while transforms and color interpolation belong on the UI thread.

Decimals need one more formatting decision: animate a stable, rounded display value rather than exposing floating-point artifacts. For currency or localized measurements, format both sides with the same locale and options, then align digits around fixed punctuation. If grouping separators can appear or disappear—999 becoming 1,000, for example—treat the separator layout as a semantic change and verify that the wider string fits before starting the roll.

Accessibility

The outer surface uses the adjustable role and exposes minimum, maximum, current numeric value, and formatted text. It also supports increment and decrement accessibility actions, which lets assistive technologies operate it as a single control. The visible plus and minus buttons remain separately labeled touch targets.

The two animated text layers are hidden from the accessibility tree so the value is not announced twice during a roll. The semantic value on the adjustable container changes once per accepted action.

With Reduce Motion enabled, the number crossfades over 120 ms rather than traveling a full line height. The value and tint feedback remain intact. If even a brief fade is inappropriate for your audience, set the progress directly to one.

Performance

All frame-by-frame work comes from one shared value. Translation, opacity, scale, and background color are derived in animated styles on the UI thread. React renders only when a button changes the number.

Keep rolling windows clipped and narrowly sized; drawing large off-screen text layers increases overdraw. Avoid recreating formatters inside render for locale-heavy counters—memoize Intl.NumberFormat at module or component scope when localization is required.

If a value streams many times per second, decide whether every intermediate number matters. For telemetry, coalesce semantic updates and animate toward the newest value. For money, scores, and inventory, preserve correctness and make sure skipped visual frames do not imply skipped data.

Profile with the final font loaded. Font fallback can change line height and clipping, producing a counter that works during development but trims glyphs in production. Measure a representative string when geometry is responsive, and leave vertical breathing room for accents, minus signs, and the chosen unit.

Common pitfalls

  • Resetting progress before React commits new text. The old incoming string may flash. Start the transition in useLayoutEffect after state updates.
  • Using proportional numerals. Different digit widths make the counter wobble. Set fontVariant: ['tabular-nums'] or use a tabular font.
  • Animating the unit with every digit. Keep stable symbols and separators outside changing slots unless they genuinely change.
  • Storing animation frames in React state. Shared values and animated styles should own frame-level progress.
  • Ignoring interruption. Rapid taps and direction changes expose assumptions that only hold for completed transitions.
  • Announcing both text layers. Hide decorative rolling glyphs and put one current value on an adjustable accessibility element.
  • Letting values escape bounds. Clamp before state, callbacks, animation, and accessibility values all diverge.

The official interpolateColor reference describes the surface transition used here. If your number represents remaining time, the countdown timer guide covers wall-clock accuracy and expiration.

Skip the build: production-ready version

The production-ready Rolling Temperature Stepper adds independent character rollers, interruption-safe direction changes, responsive measured geometry, press-and-hold repeat, warm and cool material tints, controlled and uncontrolled APIs, and imperative controls. It includes reduced-motion and adjustable semantics and is tested across iOS, Android, and web.

FAQ

How do I animate numbers in React Native?

Keep the semantic number in React state and animate a Reanimated shared progress value between the old and new formatted strings. Clip the text layers and translate them in opposite directions for a rolling effect.

How can I make only changed digits roll?

Pad the old and new formatted strings to equal length, compare characters by index, and render each position in its own clipped window. Every changed slot can still derive from one shared transition progress.

Why do animated counter digits shift horizontally?

The font probably uses proportional numeral widths. Use fontVariant: ['tabular-nums'] or a font with tabular figures and give each digit slot a fixed width.

Should an animated counter use a spring or timing animation?

Short timing curves are easier to read for frequent data changes, while a restrained spring can add tactility to occasional stepper actions. Whichever you choose, honor Reduce Motion and keep the semantic value immediately correct.