Stepper
An animated stepper with plus/minus button
Last updated on
Manual
Install the following dependencies:
npm install react-native-reanimated @expo/vector-iconsCopy and paste the following code into your project.
component/molecules/stepper
import React, { useCallback, memo } from "react";import { View, Pressable, StyleSheet, ViewStyle, ColorValue,} from "react-native";import Animated, { useSharedValue, useAnimatedStyle, withTiming, SharedValue, interpolateColor, interpolate,} from "react-native-reanimated";import { AntDesign } from "@expo/vector-icons";import type { StepperProps } from "./types";const DIVIDER_HEIGHT: number = 12;const DEFAULT_ICON_SIZE: number = 16;const PRESSED_BG_COLOR: ColorValue = "#D1D1D6";const NORMAL_BG_COLOR: ColorValue = "#ebebebff";const ANIMATION_PRESS_IN_DURATION: number = 300;const ANIMATION_PRESS_OUT_DURATION: number = 350;const DIVIDER_FADE_DURATION: number = 250;const SCALE_OUT_VALUE: number = 0.85;const Stepper: React.FC<StepperProps> = memo( ({ value, onValueChange, min = 0, max = 100, step = 1, disabled = false, disabledOpacity = 0.4, containerStyle, buttonStyle, renderIncrementIcon, renderDecrementIcon, activeBackgroundColor, inActiveBackgroundColor, size, }) => { const incrementBgProgress: SharedValue<number> = useSharedValue(0); const decrementBgProgress: SharedValue<number> = useSharedValue(0); const handleIncrementPressIn = useCallback(() => { if (disabled || value >= max) return; incrementBgProgress.value = withTiming(1, { duration: ANIMATION_PRESS_IN_DURATION, }); }, [disabled, value, max, incrementBgProgress]); const handleIncrementPressOut = useCallback(() => { incrementBgProgress.value = withTiming(0, { duration: ANIMATION_PRESS_OUT_DURATION, }); }, [incrementBgProgress]); const handleIncrement = useCallback(() => { if (disabled || value >= max) return; onValueChange(Math.min(value + step, max)); }, [value, max, step, disabled, onValueChange]); const handleDecrementPressIn = useCallback(() => { if (disabled || value <= min) return; decrementBgProgress.value = withTiming(1, { duration: ANIMATION_PRESS_IN_DURATION, }); }, [disabled, value, min, decrementBgProgress]); const handleDecrementPressOut = useCallback(() => { decrementBgProgress.value = withTiming(0, { duration: ANIMATION_PRESS_OUT_DURATION, }); }, [decrementBgProgress]); const handleDecrement = useCallback(() => { if (disabled || value <= min) return; onValueChange(Math.max(value - step, min)); }, [value, min, step, disabled, onValueChange]); const decrementAnimatedStyle = useAnimatedStyle(() => { const backgroundColor = interpolateColor( decrementBgProgress.value, [0, 1], [ inActiveBackgroundColor || NORMAL_BG_COLOR, activeBackgroundColor || PRESSED_BG_COLOR, ], ); return { backgroundColor, }; }); const incrementAnimatedStyle = useAnimatedStyle(() => { const backgroundColor = interpolateColor( incrementBgProgress.value, [0, 1], [NORMAL_BG_COLOR, PRESSED_BG_COLOR], ); return { backgroundColor, }; }); const animatedDecrementIconStylez = useAnimatedStyle(() => { const scale = interpolate( decrementBgProgress.value, [0, 1], [1, SCALE_OUT_VALUE], ); return { transform: [{ scale }], }; }); const animatedIncrementIconStylez = useAnimatedStyle(() => { const scale = interpolate( incrementBgProgress.value, [0, 1], [1, SCALE_OUT_VALUE], ); return { transform: [{ scale }], }; }); const isDecrementDisabled: boolean = disabled || value <= min; const isIncrementDisabled: boolean = disabled || value >= max; const DefaultDecrementIcon: React.FC = memo(() => ( <Animated.View style={[animatedDecrementIconStylez]}> <AntDesign name="minus" size={DEFAULT_ICON_SIZE} color="black" /> </Animated.View> )); const DefaultIncrementIcon: React.FC = memo(() => ( <Animated.View style={[animatedIncrementIconStylez]}> <AntDesign name="plus" size={DEFAULT_ICON_SIZE} color="black" /> </Animated.View> )); const animatedDividerStyles = useAnimatedStyle(() => { const isPressed = decrementBgProgress.value > 0 || incrementBgProgress.value > 0; const opacity = withTiming(isPressed ? 0 : 1, { duration: DIVIDER_FADE_DURATION, }); return { opacity, }; }); return ( <View style={[styles.wrapper, containerStyle]}> <View style={[ styles.container, { width: size ? size * 2.5 : 100, height: size ? size : 40, }, ]} > <Animated.View style={[styles.buttonContainer, decrementAnimatedStyle]} > <Pressable onPress={handleDecrement} onPressIn={handleDecrementPressIn} onPressOut={handleDecrementPressOut} disabled={isDecrementDisabled} hitSlop={20} style={[ styles.button, buttonStyle, isDecrementDisabled && { opacity: disabledOpacity }, ]} > {renderDecrementIcon ? ( renderDecrementIcon() ) : ( <DefaultDecrementIcon /> )} </Pressable> </Animated.View> <Animated.View style={[styles.divider, animatedDividerStyles]} /> <Animated.View style={[styles.buttonContainer, incrementAnimatedStyle]} > <Pressable onPress={handleIncrement} onPressIn={handleIncrementPressIn} onPressOut={handleIncrementPressOut} disabled={isIncrementDisabled} style={[ styles.button, buttonStyle, isIncrementDisabled && { opacity: disabledOpacity }, ]} hitSlop={20} > {renderIncrementIcon ? ( renderIncrementIcon() ) : ( <DefaultIncrementIcon /> )} </Pressable> </Animated.View> </View> </View> ); },);Stepper.displayName = "Stepper";interface Styles { wrapper: ViewStyle; container: ViewStyle; buttonContainer: ViewStyle; button: ViewStyle; divider: ViewStyle;}const styles = StyleSheet.create<Styles>({ wrapper: {}, container: { flexDirection: "row", alignItems: "center", justifyContent: "center", backgroundColor: NORMAL_BG_COLOR, overflow: "hidden", borderRadius: 12, }, buttonContainer: { alignItems: "center", justifyContent: "center", paddingHorizontal: 16, paddingVertical: 8, }, button: { alignItems: "center", justifyContent: "center", }, divider: { width: 1, height: DIVIDER_HEIGHT, backgroundColor: "#C7C7CC", },});export default Stepper;Usage
import { View, Text, StyleSheet } from "react-native";import { GestureHandlerRootView } from "react-native-gesture-handler";import { StatusBar } from "expo-status-bar";import { useFonts } from "expo-font";import { SymbolView } from "expo-symbols";import Stepper from "@/components/molecules/stepper";import { useState } from "react";export default function App() { const [fontLoaded] = useFonts({ SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"), HelveticaNowDisplay: require("@/assets/fonts/HelveticaNowDisplayMedium.ttf"), }); const [count, setCount] = useState(5); return ( <GestureHandlerRootView style={styles.container}> <StatusBar style="light" /> <View style={styles.content}> <Text style={styles.animatedText}>{count}</Text> <Stepper value={count} onValueChange={setCount} min={0} max={10} step={1} /> </View> </GestureHandlerRootView> );}const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#0a0a0a", }, content: { paddingHorizontal: 25, paddingTop: 100, alignItems: "center", }, animatedText: { fontSize: 50, color: "#fff", marginBottom: 40, },});Props
React Native Reanimated
Expo Vector Icons
