Pulse Animated Button

React Native button that emits outward pulsing animations while idle. Background color interpolates on press. Built with Reanimated.

reanimatedbuttonanimationreact-nativeicon

Installation

PulseAnimatedButton.tsx
import { ReactElement, useEffect } from "react";
import { ActivityIndicator, Pressable, StyleSheet, Text } from "react-native";
import Animated, {
  cancelAnimation,
  Easing,
  interpolate,
  interpolateColor,
  ReduceMotion,
  useAnimatedStyle,
  useSharedValue,
  withDelay,
  withRepeat,
  withSequence,
  withTiming,
} from "react-native-reanimated";

export type PulsingButtonProps = {
  accessibilityHint?: string;
  accessibilityLabel?: string;
  Icon?: ReactElement;
  isDisabled?: boolean;
  isLoading?: boolean;
  onPress: () => void;
  title: string;
  buttonColor: string;
  buttonTouchColor: string;
  textColor: string;
  reduceMotion?: "never" | "always" | "system";
}

export type PulseProps = {
  index: number;
  pulseColor: string
  reduceMotion: string;
  isDisabled?: boolean;
  isLoading?: boolean;

}

const BACKGROUND_TRANSITION_DURATION = 300;
const BORDER_RADIUS = 8;
const HEIGHT = 42;
const NUMBER_OF_PULSES = 2;
const PULSE_TRANSITION_DURATION = 2000;
const PULSE_DELAY = 700;

const Pulse = ({ index, isDisabled, isLoading, pulseColor, reduceMotion }: PulseProps) => {
  const transition = useSharedValue(0);

  const motion =
  reduceMotion === "never"
      ? ReduceMotion.Never
      : reduceMotion === "always"
          ? ReduceMotion.Always
          : ReduceMotion.System;

  useEffect(() => {
      if (isDisabled || isLoading) {
          cancelAnimation(transition);
          transition.value = 0;
          return;
      }

      transition.value = withRepeat(
          withSequence(
              withDelay(
                  PULSE_DELAY * index,
                  withTiming(1, {
                      duration:
                          PULSE_TRANSITION_DURATION +
                          PULSE_DELAY * (NUMBER_OF_PULSES - index - 1),
                      easing: Easing.out(Easing.ease),
                      reduceMotion: motion
                  })
              ),
              withTiming(0, { duration: 0 })
          ),
          -1,
          false,
          () => {},
          motion
      );

      return () => {
          cancelAnimation(transition);
      };
  }, [index, isDisabled, isLoading, transition]);

  const animatedStyle = useAnimatedStyle(() => ({
      opacity: interpolate(transition.value, [0, 1], [0.5, 0]),
      transform: [
          {
              scale: interpolate(transition.value, [0, 1], [1, 1.5]),
          },
      ],
  }));

  return <Animated.View style={[styles.pulse, animatedStyle, {backgroundColor: pulseColor}]} />;
};

export const PulseAnimatedButton = ({
  accessibilityHint,
  accessibilityLabel,
  Icon,
  isDisabled = false,
  isLoading = false,
  onPress,
  title,
  buttonColor,
  buttonTouchColor,
  textColor,
  reduceMotion = "system",
}: PulsingButtonProps) => {
  const backgroundTransition = useSharedValue(0);
  const isActive = useSharedValue(false);

  const motion =
  reduceMotion === "never"
      ? ReduceMotion.Never
      : reduceMotion === "always"
          ? ReduceMotion.Always
          : ReduceMotion.System;

  const animatedContainerStyle = useAnimatedStyle(() => ({
      backgroundColor: interpolateColor(
          backgroundTransition.value,
          [0, 1],
          [buttonColor, buttonTouchColor]
      ),
  }));

  return (
      <Pressable
          accessibilityHint={accessibilityHint}
          accessibilityLabel={accessibilityLabel}
          accessibilityRole="button"
          accessibilityState={{
              busy: isLoading,
              disabled: isDisabled || isLoading,
          }}
          disabled={isDisabled || isLoading}
          
          onPress={onPress}
          onPressIn={() => {
              isActive.value = true;
              backgroundTransition.value = withTiming(
                  1,
                  { duration: BACKGROUND_TRANSITION_DURATION },
                  () => {
                      if (!isActive.value) {
                          backgroundTransition.value = withTiming(0, {
                              duration: BACKGROUND_TRANSITION_DURATION,
                              reduceMotion: motion
                          });
                      }
                  }
              );
          }}
          onPressOut={() => {
              if (backgroundTransition.value === 1) {
                  backgroundTransition.value = withTiming(0, {
                      duration: BACKGROUND_TRANSITION_DURATION,
                      reduceMotion: motion
                  });
              }
              isActive.value = false;
          }}
      >
          {Array.from({ length: NUMBER_OF_PULSES }).map((_, index) => (
              <Pulse
                  key={index}
                  index={index}
                  reduceMotion={reduceMotion}
                  isDisabled={isDisabled}
                  isLoading={isLoading}
                  pulseColor={buttonColor}
              />
          ))}
          <Animated.View
              style={[
                  styles.container,
                  animatedContainerStyle,
                  { opacity: isDisabled ? 0.5 : 1 },
              ]}
          >
              {isLoading ? (
                  <ActivityIndicator
                      color={textColor}
                      size={18}
                  />
              ) : (
                  <>
                      {Icon}
                      <Text numberOfLines={1} style={[styles.title, {color: textColor}]}>
                          {title}
                      </Text>
                  </>
              )}
          </Animated.View>
      </Pressable>
  );
};

const styles = StyleSheet.create({
  container: {
      alignItems: "center",
      borderRadius: BORDER_RADIUS,
      flexDirection: "row",
      gap: 8,
      height: HEIGHT,
      justifyContent: "center",
      paddingHorizontal: 12,
      paddingVertical: 8,
  },
  pulse: {
      // backgroundColor: theme.colors.primary,
      borderRadius: BORDER_RADIUS,
      height: HEIGHT,
      position: "absolute",
      width: "100%",
  },
  title: {
      // color: theme.colors.textInverted,
      flexShrink: 1,
      fontSize: 18,
      fontWeight: "600",
  },
});

Usage

App.tsx
import React, { useState } from 'react';
import { View, StyleSheet, Alert } from 'react-native';
import { PulseAnimatedButton } from "@/components/ui/Buttons/PulseAnimatedButton";

export default function App() {
const [isLoading, setIsLoading] = useState(false);

const handleLoadingPress = () => {
  setIsLoading(true);
  setTimeout(() => {
    setIsLoading(false);
  }, 2500); 
};

return (
      <PulseAnimatedButton
      buttonColor={colors.AuxColorTwo}
      textColor={colors.Neutral0}
      buttonTouchColor={colors.AuxColorThree}
      onPress={() => {}}
      title="Pulse Action"
      reduceMotion="never"
  />
);}

Props

PropTypeDefaultRequiredDescription
titlestringYesText label displayed on the button.
onPress() => voidYesFunction called when the button is pressed.
IconReactElementNoOptional icon element displayed left of the title.
isDisabledbooleanfalseNoDisables button interaction and styles it.
isLoadingbooleanfalseNoShows loading indicator instead of title/icon.
reduceMotion'never' | 'always' | 'system''system'NoControls if animation respects system motion settings.
accessibilityHintstringNoAccessibility hint for screen readers.
accessibilityLabelstringNoMain accessibility label for the button.