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
Prop | Type | Default | Required | Description |
---|---|---|---|---|
title | string | Yes | Text label displayed on the button. | |
onPress | () => void | Yes | Function called when the button is pressed. | |
Icon | ReactElement | No | Optional icon element displayed left of the title. | |
isDisabled | boolean | false | No | Disables button interaction and styles it. |
isLoading | boolean | false | No | Shows loading indicator instead of title/icon. |
reduceMotion | 'never' | 'always' | 'system' | 'system' | No | Controls if animation respects system motion settings. |
accessibilityHint | string | No | Accessibility hint for screen readers. | |
accessibilityLabel | string | No | Main accessibility label for the button. |