Spin Button

A pressable button that switches to a loading state

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/micro-interactions/spin-button.tsx

/** * Animation inspired by: * https://x.com/dev_ya/status/1991193618787254462 * Interaction design by Yanis Lebzar. */import { StyleSheet, Pressable } from "react-native";import React, { useState, useCallback } from "react";import Animated, {  useAnimatedStyle,  useSharedValue,  withTiming,  withRepeat,  Easing,  interpolateColor,  LinearTransition,  withDelay,  withSpring,} from "react-native-reanimated";import { CircularLoader } from "@/components/molecules/Loaders/circular";import type {  SpinButtonProps,  AnimationConfig,  CharacterAnimationParams,  TextAnimationProps,  CharacterProps,} from "./types";import {  BUTTON_SCALE,  DEFAULT_ANIMATION_CONFIG,  DEFAULT_BUTTON_COLORS,  DEFAULT_CHARACTER_ENTER_FINAL,  DEFAULT_CHARACTER_ENTER_INITIAL,  DEFAULT_CHARACTER_EXIT_FINAL,  DEFAULT_CHARACTER_EXIT_INITIAL,  DEFAULT_SPINNER_CONFIG,  DEFAULT_BUTTON_STYLE,} from "./conf";const mergeDeep = <T extends Record<string, any>>(  target: T,  source: Partial<T>,): T => {  const output = { ...target };  for (const key in source) {    if (      source[key] &&      typeof source[key] === "object" &&      !Array.isArray(source[key])    ) {      output[key] = mergeDeep(        output[key] as Record<string, any>,        source[key] as Record<string, any>,      ) as T[Extract<keyof T, string>];    } else if (source[key] !== undefined) {      output[key] = source[key] as T[Extract<keyof T, string>];    }  }  return output;};const StaggeredText: React.FC<  TextAnimationProps & {    readonly animationConfig: AnimationConfig;    readonly enterInitial: CharacterAnimationParams;    readonly enterFinal: CharacterAnimationParams;    readonly exitInitial: CharacterAnimationParams;    readonly exitFinal: CharacterAnimationParams;  }> = ({  text,  style,  animationConfig,  enterInitial,  enterFinal,  exitInitial,  exitFinal,}) => {  const characters = Array.from(text);  return (    <Animated.View      style={styles.textWrapper}      layout={LinearTransition.duration(        animationConfig.buttonTransitionDuration,      ).easing(animationConfig.timing.easing!)}    >      {characters.map((char, index) => (        <Character          key={`${char}-${index}`}          char={char}          style={style}          index={index}          animationConfig={animationConfig}          enterInitial={enterInitial}          enterFinal={enterFinal}          exitInitial={exitInitial}          exitFinal={exitFinal}        />      ))}    </Animated.View>  );};const Character: React.FC<CharacterProps> = ({  char,  style,  index,  animationConfig,  enterInitial,  enterFinal,  exitInitial,  exitFinal,}) => {  const animationDelay = (index + 1) * animationConfig.characterDelay;  const enteringAnimation = () => {    "worklet";    const springConfig = animationConfig.spring;    const timingConfig = {      duration: animationConfig.characterEnterDuration,    };    return {      initialValues: {        opacity: enterInitial.opacity,        transform: [          { translateY: enterInitial.translateY },          { scale: enterInitial.scale },        ],      },      animations: {        opacity: withDelay(          animationDelay,          withTiming(enterFinal.opacity, timingConfig),        ),        transform: [          {            translateY: withDelay(              animationDelay,              withSpring(enterFinal.translateY, springConfig),            ),          },          {            scale: withDelay(              animationDelay,              withSpring(enterFinal.scale, springConfig),            ),          },        ],      },    };  };  const exitingAnimation = () => {    "worklet";    const timingConfig = {      duration: animationConfig.characterExitDuration,    };    return {      initialValues: {        opacity: exitInitial.opacity,        transform: [          { translateY: exitInitial.translateY },          { scale: exitInitial.scale },        ],      },      animations: {        opacity: withDelay(          animationDelay,          withTiming(exitFinal.opacity, timingConfig),        ),        transform: [          {            translateY: withDelay(              animationDelay,              withTiming(exitFinal.translateY, timingConfig),            ),          },          {            scale: withDelay(              animationDelay,              withTiming(exitFinal.scale, timingConfig),            ),          },        ],      },    };  };  return (    <Animated.Text      entering={enteringAnimation}      exiting={exitingAnimation}      layout={LinearTransition.duration(180).easing(        animationConfig.timing.easing!,      )}      style={[style]}    >      {char}    </Animated.Text>  );};const SpinButton: React.FC<SpinButtonProps> = ({  idleText = "Save",  activeText = "Saving",  colors,  animationConfig,  spinnerConfig,  buttonStyle,  onPress,  onStateChange,  initialState = false,  disabled = false,  controlled = false,  isActive,}) => {  const [internalState, setInternalState] = useState<boolean>(initialState);  const isSaving = controlled ? (isActive ?? false) : internalState;  const mergedColors = mergeDeep(DEFAULT_BUTTON_COLORS, colors ?? {});  const mergedAnimationConfig = mergeDeep(    DEFAULT_ANIMATION_CONFIG,    animationConfig ?? {},  );  const mergedSpinnerConfig = mergeDeep(    DEFAULT_SPINNER_CONFIG,    spinnerConfig ?? {},  );  const mergedButtonStyle = mergeDeep(DEFAULT_BUTTON_STYLE, buttonStyle ?? {});  const buttonScale = useSharedValue<number>(1);  const buttonBackgroundProgress = useSharedValue<number>(initialState ? 1 : 0);  const textColorProgress = useSharedValue<number>(initialState ? 1 : 0);  const spinnerScale = useSharedValue<number>(initialState ? 1 : 0);  const spinnerRotation = useSharedValue<number>(0);  const handlePress = useCallback<() => void>((): void => {    if (disabled) return;    const newState = !isSaving;    if (!controlled) {      setInternalState(newState);    }    onPress?.(newState);    onStateChange?.(newState);    const colorTimingConfig = {      duration: mergedAnimationConfig.colorTransitionDuration,      easing: mergedAnimationConfig.timing.easing,    };    buttonBackgroundProgress.value = withTiming(      newState ? 1 : 0,      colorTimingConfig,    );    textColorProgress.value = withTiming(newState ? 1 : 0, colorTimingConfig);    if (newState) {      spinnerScale.value = withTiming(1, {        duration: mergedAnimationConfig.spinnerEnterDuration,        easing: Easing.bezier(0.34, 1.56, 0.64, 1),      });      spinnerRotation.value = withRepeat<number>(        withTiming<number>(360, {          duration: 1000,          easing: Easing.linear,        }),        -1,        false,      );    } else {      spinnerScale.value = withTiming<number>(0, {        duration: mergedAnimationConfig.spinnerExitDuration,        easing: Easing.ease,      });      spinnerRotation.value = withTiming<number>(0, {        duration: mergedAnimationConfig.spinnerExitDuration,      });    }    buttonScale.value = withTiming<number>(BUTTON_SCALE.pressed, {      duration: mergedAnimationConfig.buttonPressDuration,    });    buttonScale.value = withTiming<number>(BUTTON_SCALE.released, {      duration: mergedAnimationConfig.buttonReleaseDuration,      easing: Easing.out(Easing.ease),    });  }, [    disabled,    isSaving,    controlled,    onPress,    onStateChange,    mergedAnimationConfig,    buttonBackgroundProgress,    textColorProgress,    spinnerScale,    spinnerRotation,    buttonScale,  ]);  const animatedButtonStyle = useAnimatedStyle(() => {    const backgroundColor = interpolateColor(      buttonBackgroundProgress.value,      [0, 1],      [mergedColors.idle.background, mergedColors.active.background],    );    return {      transform: [{ scale: buttonScale.value }],      backgroundColor,      opacity: disabled ? 0.5 : 1,    };  });  const animatedTextStyle = useAnimatedStyle(() => {    const color = interpolateColor(      textColorProgress.value,      [0, 1],      [mergedColors.idle.text, mergedColors.active.text],    );    return {      color,    };  });  const animatedSpinnerContainerStyle = useAnimatedStyle(() => {    return {      transform: [{ scale: spinnerScale.value }],      opacity: spinnerScale.value,      backgroundColor: interpolateColor(        spinnerScale.value,        [0, 1],        ["transparent", mergedSpinnerConfig.containerBackground],      ),    };  });  return (    <Pressable onPress={handlePress} disabled={disabled}>      <Animated.View        style={[          {            paddingHorizontal: mergedButtonStyle.paddingHorizontal,            paddingVertical: mergedButtonStyle.paddingVertical,            borderRadius: mergedButtonStyle.borderRadius,            flexDirection: "row",            alignItems: "center",            justifyContent: "center",            position: "relative",          },          animatedButtonStyle,        ]}        layout={LinearTransition.duration(          mergedAnimationConfig.buttonTransitionDuration,        ).easing(mergedAnimationConfig.timing.easing!)}      >        <StaggeredText          text={isSaving ? activeText : idleText}          style={[            {              fontSize: mergedButtonStyle.fontSize,              fontWeight: mergedButtonStyle.fontWeight,            },            animatedTextStyle,          ]}          animationConfig={mergedAnimationConfig}          enterInitial={DEFAULT_CHARACTER_ENTER_INITIAL}          enterFinal={DEFAULT_CHARACTER_ENTER_FINAL}          exitInitial={DEFAULT_CHARACTER_EXIT_INITIAL}          exitFinal={DEFAULT_CHARACTER_EXIT_FINAL}        />        <Animated.View          style={[            {              position: "absolute",              right: mergedSpinnerConfig.position.right,              bottom: mergedSpinnerConfig.position.bottom,              width: mergedSpinnerConfig.containerSize,              height: mergedSpinnerConfig.containerSize,              backgroundColor: mergedSpinnerConfig.containerBackground,              borderRadius: 99,              justifyContent: "center",              alignItems: "center",            },            animatedSpinnerContainerStyle,          ]}        >          <Animated.View            style={{              width: mergedSpinnerConfig.size,              height: mergedSpinnerConfig.size,              justifyContent: "center",              alignItems: "center",            }}          >            <CircularLoader              activeColor={mergedSpinnerConfig.color}              size={mergedSpinnerConfig.size}              strokeWidth={mergedSpinnerConfig.strokeWidth}              duration={800}            />          </Animated.View>        </Animated.View>      </Animated.View>    </Pressable>  );};export default SpinButton;const styles = StyleSheet.create({  textWrapper: {    flexDirection: "row",    flexWrap: "wrap",  },});

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 { CountdownTimer } from "@/components/micro-interactions/countdown";import { Ionicons } from "@expo/vector-icons";import { FlexiButton } from "@/components/micro-interactions/flexi-button";import GooeySwitch from "@/components/micro-interactions/gooey-switch";import Hamburger from "@/components/micro-interactions/hamburger";import Animated, {  LinearTransition,  useSharedValue,  withTiming,} from "react-native-reanimated";import SpinButton from "@/components/micro-interactions/spin-button";export default function App() {  const [fontLoaded] = useFonts({    SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"),    HelveticaNowDisplay: require("@/assets/fonts/HelveticaNowDisplayMedium.ttf"),    Coolvetica: require("@/assets/fonts/Coolvetica-Rg.otf"),  });  const launchDate = new Date("2026-07-20T14:30:00");  const progress = useSharedValue(0);  return (    <GestureHandlerRootView style={styles.container}>      <StatusBar style="light" />      <Animated.View style={styles.content} layout={LinearTransition}>        <SpinButton          colors={{            active: {              background: "#121212",              text: "#fff",            },          }}          spinnerConfig={{            containerBackground: "#121212",          }}          animationConfig={{            spring: {              damping: 20,              stiffness: 250,              mass: 0.9,            },          }}        />      </Animated.View>    </GestureHandlerRootView>  );}const styles = StyleSheet.create({  container: {    flex: 1,    backgroundColor: "#000000",  },  content: {    alignItems: "center",    gap: 24,    top: 80,  },  iconBox: {    width: 64,    height: 64,    borderRadius: 20,    backgroundColor: "#1a1a1a",    justifyContent: "center",    alignItems: "center",    marginBottom: 8,  },  label: {    fontSize: 14,    color: "#555",    textTransform: "uppercase",    letterSpacing: 2,  },  date: {    fontSize: 15,    color: "#333",    marginTop: 8,  },});

Props

React Native Reanimated