Switch

An animated toggle switch with spring motion

Last updated on

Edit on GitHub

Manual

Install the following dependencies:

npm install react-native-reanimated

Copy and paste the following code into your project. component/base/AnimatedSwitch.tsx

import React, { memo, useEffect } from "react";import { StyleSheet, Pressable, type ViewStyle } from "react-native";import Animated, {  useSharedValue,  useAnimatedStyle,  withSpring,  interpolateColor,  useDerivedValue,  withTiming,  withSequence,} from "react-native-reanimated";import type { AnimatedSwitchProps } from "./AnimatedSwitch.types";export const AnimatedSwitch: React.FC<AnimatedSwitchProps> &  React.FunctionComponent<AnimatedSwitchProps> = memo<AnimatedSwitchProps>(  ({    value,    onValueChange,    disabled = false,    width = 56,    height = 32,    onColor = "#4CD964",    offColor = "#E9E9EA",    thumbColor = "#FFFFFF",    thumbSize,    thumbInset = 2,    springConfig = {      damping: 15,      stiffness: 120,      mass: 1,    },    style,    testID,    thumbOnIcon,    thumbOffIcon,    trackOnIcon,    trackOffIcon,    backgroundImage,    backgroundImageStyle,    animateIcons = true,    iconAnimationType = "fade",  }: AnimatedSwitchProps): React.ReactNode &    React.JSX.Element &    React.ReactNode => {    const finalThumbSize = thumbSize ?? height - thumbInset * 2;    const moveDistance = width - finalThumbSize - thumbInset * 2;    const position = useSharedValue<number>(value ? 1 : 0);    const iconProgress = useSharedValue<number>(value ? 1 : 0);    const iconBounce = useSharedValue<number>(1);    useEffect(() => {      position.value = value ? 1 : 0;      if (animateIcons) {        iconProgress.value = withTiming(value ? 1 : 0, { duration: 200 });        if (iconAnimationType === "bounce") {          iconBounce.value = withSequence(            withTiming(1.3, { duration: 100 }),            withTiming(1, { duration: 100 }),          );        }      } else {        iconProgress.value = value ? 1 : 0;      }    }, [      value,      position,      iconProgress,      iconBounce,      animateIcons,      iconAnimationType,    ]);    const backgroundColor = useDerivedValue<string>(() => {      return withSpring(        interpolateColor(position.value, [0, 1], [offColor, onColor]),      );    });    const thumbStylez = useAnimatedStyle<Pick<ViewStyle, "transform">>(() => {      return {        transform: [          {            translateX: withSpring(position.value * moveDistance, springConfig),          },        ],      };    });    const backgroundStyle = useAnimatedStyle<      Pick<ViewStyle, "backgroundColor">    >(() => {      return {        backgroundColor: backgroundColor.value,      };    });    const thumbOnIconStyle = useAnimatedStyle(() => {      const baseOpacity = iconProgress.value;      switch (iconAnimationType) {        case "fade":          return { opacity: baseOpacity };        case "rotate":          return {            opacity: baseOpacity,            transform: [{ rotate: `${iconProgress.value * 360}deg` }],          };        case "scale":          return {            opacity: baseOpacity,            transform: [{ scale: iconProgress.value }],          };        case "bounce":          return {            opacity: baseOpacity,            transform: [{ scale: baseOpacity * iconBounce.value }],          };        default:          return { opacity: baseOpacity };      }    });    const thumbOffIconStyle = useAnimatedStyle(() => {      const baseOpacity = 1 - iconProgress.value;      switch (iconAnimationType) {        case "fade":          return { opacity: baseOpacity };        case "rotate":          return {            opacity: baseOpacity,            transform: [{ rotate: `${(1 - iconProgress.value) * 360}deg` }],          };        case "scale":          return {            opacity: baseOpacity,            transform: [{ scale: 1 - iconProgress.value }],          };        case "bounce":          return {            opacity: baseOpacity,            transform: [{ scale: baseOpacity * iconBounce.value }],          };        default:          return { opacity: baseOpacity };      }    });    const handlePress = () => {      if (disabled) return;      const newValue = !value;      onValueChange(newValue);    };    const trackOnIconStyle = useAnimatedStyle(() => {      const baseOpacity = iconProgress.value;      switch (iconAnimationType) {        case "fade":          return { opacity: baseOpacity };        case "scale":          return {            opacity: baseOpacity,            transform: [{ scale: iconProgress.value * 0.5 + 0.5 }],          };        case "rotate":          return {            opacity: baseOpacity,            transform: [{ rotate: `${iconProgress.value * 90}deg` }],          };        case "bounce":          return {            opacity: baseOpacity,            transform: [{ scale: iconBounce.value }],          };        default:          return { opacity: baseOpacity };      }    });    const trackOffIconStyle = useAnimatedStyle(() => {      const baseOpacity = 1 - iconProgress.value;      switch (iconAnimationType) {        case "fade":          return { opacity: baseOpacity };        case "scale":          return {            opacity: baseOpacity,            transform: [{ scale: (1 - iconProgress.value) * 0.5 + 0.5 }],          };        case "rotate":          return {            opacity: baseOpacity,            transform: [{ rotate: `${(1 - iconProgress.value) * 90}deg` }],          };        case "bounce":          return {            opacity: baseOpacity,            transform: [{ scale: iconBounce.value }],          };        default:          return { opacity: baseOpacity };      }    });    return (      <Pressable        onPress={handlePress}        disabled={disabled}        testID={testID}        style={({ pressed }) => [          { opacity: pressed || disabled ? 0.7 : 1 },          style,        ]}      >        <Animated.View          style={[            styles.track,            backgroundStyle,            {              width,              height,              borderRadius: height / 2,              overflow: "hidden",            },          ]}        >          {backgroundImage && (            <Animated.Image              source={backgroundImage}              style={[styles.backgroundImage, backgroundImageStyle]}              resizeMode="cover"            />          )}          {trackOnIcon && (            <Animated.View              style={[                styles.trackIconContainer,                { justifyContent: "flex-start", alignItems: "flex-start" },                trackOnIconStyle,              ]}            >              {trackOnIcon}            </Animated.View>          )}          {trackOffIcon && (            <Animated.View              style={[                styles.trackIconContainer,                { justifyContent: "flex-end", alignItems: "flex-end" },                trackOffIconStyle,              ]}            >              {trackOffIcon}            </Animated.View>          )}          <Animated.View            style={[              styles.thumb,              thumbStylez,              {                width: finalThumbSize,                height: finalThumbSize,                borderRadius: finalThumbSize / 2,                backgroundColor: thumbColor,                left: thumbInset,                top: thumbInset,              },            ]}          >            {thumbOnIcon && (              <Animated.View                style={[styles.thumbIconContainer, thumbOnIconStyle]}              >                {thumbOnIcon}              </Animated.View>            )}            {thumbOffIcon && (              <Animated.View                style={[styles.thumbIconContainer, thumbOffIconStyle]}              >                {thumbOffIcon}              </Animated.View>            )}          </Animated.View>        </Animated.View>      </Pressable>    );  },);const styles = StyleSheet.create({  track: {    justifyContent: "center",  },  thumb: {    position: "absolute",    shadowOffset: { width: 0, height: 2 },    shadowOpacity: 0.2,    shadowRadius: 2,    elevation: 2,    justifyContent: "center",    alignItems: "center",    zIndex: 2,  },  backgroundImage: {    position: "absolute",    width: "100%",    height: "100%",  },  trackIconContainer: {    position: "absolute",    width: "100%",    height: "100%",    padding: 8,    zIndex: 1,  },  thumbIconContainer: {    position: "absolute",    justifyContent: "center",    alignItems: "center",    width: "100%",    height: "100%",  },});export default AnimatedSwitch;

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 { useState } from "react";import AnimatedSwitch from "@/components/base/switch/AnimatedSwitch";import { Feather, Ionicons } from "@expo/vector-icons";export default function App() {  const [fontLoaded] = useFonts({    SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"),    HelveticaNowDisplay: require("@/assets/fonts/HelveticaNowDisplayMedium.ttf"),  });  const [darkMode, setDarkMode] = useState(false);  return (    <GestureHandlerRootView style={styles.container}>      <StatusBar style="light" />      <View style={styles.card}>        <AnimatedSwitch          value={darkMode}          onValueChange={setDarkMode}          onColor="#8bf26ee2"          offColor="#333"          thumbColor="#fff"          iconAnimationType="rotate"          thumbOnIcon={<Feather name="check" size={12} color="black" />}          thumbOffIcon={            <Ionicons name="close-outline" size={12} color="black" />          }          animateIcons          thumbInset={4.5}          height={40}          width={70}        />      </View>    </GestureHandlerRootView>  );}const styles = StyleSheet.create({  container: {    flex: 1,    backgroundColor: "#0a0a0a",    alignItems: "center",  },  card: {    backgroundColor: "#141414",    marginTop: 90,  },  row: {    flexDirection: "row",    alignItems: "center",    gap: 14,  },  iconBox: {    width: 44,    height: 44,    borderRadius: 12,    backgroundColor: "#1f1f1f",    justifyContent: "center",    alignItems: "center",  },  textContent: {    flex: 1,    gap: 2,  },  title: {    fontSize: 17,    fontWeight: "600",    color: "#fff",  },  subtitle: {    fontSize: 14,    color: "#666",  },});

Props

React Native Reanimated