Rolling Counter
An animated rolling counter where each digit slides vertically with spring motion
Last updated on
Manual
Install the following dependencies:
npm install react-native-reanimated expo-blur react-native-workletsCopy 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
