Rolling Counter

An animated rolling counter where each digit slides vertically with spring motion

Last updated on

Edit on GitHub

Manual

Install the following dependencies:

npm install react-native-reanimated expo-blur react-native-worklets

Copy and paste the following code into your project. component/organisms/rolling-counter

import { Platform, StyleSheet, Text, View, ViewStyle } from "react-native";import { type FC, memo, useState } from "react";import Animated, {  Easing,  interpolate,  useAnimatedProps,  useAnimatedReaction,  useAnimatedStyle,  useDerivedValue,  useSharedValue,  withSpring,  withTiming,} from "react-native-reanimated";import { BlurView, type BlurViewProps } from "expo-blur";import type { ICounter, IReusableDigit } from "./types";import { SPRING_CONFIG } from "./const";import { scheduleOnRN } from "react-native-worklets";const AnimatedBlur =  Animated.createAnimatedComponent<Partial<BlurViewProps>>(BlurView);const getDigitAtPlace = <T extends number, I extends number>(  num: T,  index: I,): number => {  "worklet";  const str = Math.abs(Math.floor(num)).toString();  return parseInt(str[str.length - 1 - index] || "0", 10);};const getDigitCount = <T extends number>(num: T): number => {  "worklet";  return Math.max(Math.abs(Math.floor(num)).toString().length, 1);};const CounterDigit: FC<IReusableDigit> = memo<IReusableDigit>(  ({    place,    counterValue,    height,    width,    color,    fontSize,    springConfig,  }: IReusableDigit):    | (React.JSX.Element & React.ReactNode & React.ReactElement)    | null => {    const currentDigit = useDerivedValue<number>(() =>      getDigitAtPlace(counterValue.value, place),    );    const slideY = useSharedValue<number>(0);    const digitSlideStylez = useAnimatedStyle<Pick<ViewStyle, "transform">>(      () => {        const targetY = -height * currentDigit.value;        slideY.value = withSpring(targetY, {          ...springConfig,        });        return {          transform: [{ translateY: slideY.value }],        };      },    );    const blurEffectPropz = useAnimatedProps<Pick<BlurViewProps, "intensity">>(      () => {        const targetY = -height * currentDigit.value;        const delta = Math.abs(slideY.value - targetY);        const isMoving = delta > 0.5;        return {          intensity: isMoving            ? withSpring<number>(interpolate(delta, [0, height], [0, 3.5]))            : 0,        };      },    );    return (      <View        style={{          height,          width,          overflow: "hidden",        }}      >        <Animated.View style={digitSlideStylez}>          {Array.from({ length: 10 }, (_, i) => (            <Text              key={i}              style={{                height,                width,                textAlign: "center",                lineHeight: height,                fontSize,                fontWeight: "bold",                color,                fontVariant: ["tabular-nums"],              }}            >              {i}            </Text>          ))}          {Platform.OS === "ios" && (            <AnimatedBlur              animatedProps={blurEffectPropz}              style={StyleSheet.absoluteFill}              pointerEvents="none"              tint="default"            />          )}        </Animated.View>      </View>    );  },);const RollingCounter: FC<ICounter> = memo(  ({    value,    height = 60,    width = 40,    fontSize = 48,    color = "#000",    springConfig = SPRING_CONFIG,  }: ICounter):    | (React.JSX.Element & React.ReactNode & React.ReactElement)    | null => {    const internalCounter = useSharedValue<number>(0);    const animatedValue = typeof value === "number" ? internalCounter : value;    const [totalDigits, setTotalDigits] = useState<number>(() => {      const initialValue = typeof value === "number" ? value : value.value;      return getDigitCount<number>(initialValue);    });    useDerivedValue<void>(() => {      if (typeof value === "number") {        internalCounter.value = value;      }    });    useAnimatedReaction<number>(      () => getDigitCount<number>(animatedValue.value),      (newCount, prevCount) => {        if (newCount !== prevCount) {          scheduleOnRN(setTotalDigits, newCount);        }      },      [animatedValue],    );    const containerAnimStyle = useAnimatedStyle<      Partial<Pick<ViewStyle, "width">>    >(() => ({      width: withTiming<number>(        getDigitCount<number>(animatedValue.value) * width,        {          duration: 250,          easing: Easing.inOut(Easing.ease),        },      ),    }));    return (      <Animated.View style={[styles.rowContainer, containerAnimStyle]}>        {Array.from({ length: totalDigits }, (_, i) => {          const placeIndex = totalDigits - 1 - i;          return (            <CounterDigit              key={placeIndex}              springConfig={springConfig}              place={placeIndex}              counterValue={animatedValue}              height={height}              width={width}              color={color}              fontSize={fontSize}            />          );        })}      </Animated.View>    );  },);const styles = StyleSheet.create({  rowContainer: {    flexDirection: "row",    overflow: "hidden",  },});export { RollingCounter };

Usage

import { StyleSheet, Text, Pressable } from "react-native";import { GestureHandlerRootView } from "react-native-gesture-handler";import { StatusBar } from "expo-status-bar";import { useSharedValue } from "react-native-reanimated";import { RollingCounter } from "@/components/organisms/rolling-counter";import { useFonts } from "expo-font";export default function App() {  const counter = useSharedValue<number>(10);  const [fontLoaded] = useFonts({    SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"),    HelveticaNowDisplay: require("@/assets/fonts/HelveticaNowDisplayMedium.ttf"),  });  const increment = () => {    counter.value = counter.value + Math.floor(Math.random() * 250) + 1;    console.log(counter.value + Math.floor(Math.random() * 250) + 1);  };  return (    <GestureHandlerRootView style={styles.container}>      <StatusBar style="light" />      <Pressable style={styles.card} onPress={increment}>        <Text          style={[            styles.label,            {              fontFamily: fontLoaded ? "SfProRounded" : undefined,            },          ]}        >          Total Downloads        </Text>        <RollingCounter          value={counter}          height={64}          width={42}          springConfig={{            stiffness: 110,            damping: 14,            mass: 0.5,          }}          fontSize={52}          color="#fff"        />      </Pressable>    </GestureHandlerRootView>  );}const styles = StyleSheet.create({  container: {    flex: 1,    backgroundColor: "#000",    alignItems: "center",  },  card: {    paddingVertical: 28,    paddingHorizontal: 32,    borderRadius: 24,    top: 100,    alignItems: "center",    backgroundColor: "rgba(255,255,255,0.06)",    borderWidth: StyleSheet.hairlineWidth,    borderColor: "rgba(255,255,255,0.12)",  },  label: {    fontSize: 13,    color: "rgba(255,255,255,0.6)",    marginBottom: 10,    textTransform: "uppercase",  },  hint: {    marginTop: 14,    fontSize: 12,    color: "rgba(255,255,255,0.4)",  },});

Props

React Native Reanimated
Expo Blur
React Native Worklets