3D Animated Button

A reusable React Native button component that simulates a 3D or cartoon-style press effect using Reanimated. It animates downwards relative to a static base, providing clear visual feedback. Includes support for icons, loading/disabled states, and respects system reduced motion settings.

reanimatedbuttonanimationreact-native3d

Installation

ThreeDimensionAnimatedButton.tsx
import { ReactElement } from "react";
import {
  ActivityIndicator,
  Pressable,
  StyleSheet,
  Text,
  View,
} from "react-native";
import Animated, {
  interpolate,
  useAnimatedStyle,
  useSharedValue,
  withTiming,
  ReduceMotion,
} from "react-native-reanimated";

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

const DURATION = 150; // Reduced duration often feels better for this effect
const BORDER_RADIUS = 8;
const HEIGHT = 42;
const SHADOW_HEIGHT = 8; // Thickness of the 3D effect "base"

export const ThreeDimensionAnimatedButton = ({
  accessibilityHint,
  accessibilityLabel,
  Icon,
  isDisabled = false,
  isLoading = false,
  onPress,
  buttonColor,
  buttonShadowColor,
  textColor,
  title,
  reduceMotion = "system",
}: AnimatedCartoonButtonProps) => {
  const transition = useSharedValue(0); // 0 = up, 1 = pressed down
  const isActive = useSharedValue(false); // Track if press is still active

  // Determine the ReduceMotion setting
  const motionSetting =
  	reduceMotion === "never"
  		? ReduceMotion.Never
  		: reduceMotion === "always"
  			? ReduceMotion.Always
  			: ReduceMotion.System;

  // Define animation config once
  const timingConfigPress = {
  	duration: DURATION,
  	reduceMotion: motionSetting,
  };
  const timingConfigRelease = {
  	duration: DURATION,
  	reduceMotion: motionSetting,
  };

  // Animated style for the main button content layer
  const animatedButtonStyle = useAnimatedStyle(() => {
  	return {
  		// Interpolate top position: 0 (up) to SHADOW_HEIGHT (down)
  		top: interpolate(transition.value, [0, 1], [0, SHADOW_HEIGHT]),
  		// Apply opacity for disabled state here
  		opacity: isDisabled ? 0.5 : 1,
  		// Set background color here
  		backgroundColor: buttonColor, // Use your primary button color
  	};
  });

  return (
  	<Pressable
  		accessibilityHint={accessibilityHint}
  		accessibilityLabel={accessibilityLabel}
  		accessibilityRole="button"
  		accessibilityState={{
  			busy: isLoading,
  			disabled: isDisabled || isLoading,
  		}}
  		disabled={isDisabled || isLoading}
  		
  		onPress={onPress}
  		onPressIn={() => {
  			isActive.value = true;
  			// Animate to pressed state (value = 1)
  			transition.value = withTiming(1, timingConfigPress, (finished) => {
  				// If released *during* the press animation, animate back up
  				if (!isActive.value && finished !== false) { // Check finished is not explicitly false (interrupted)
  					transition.value = withTiming(0, timingConfigRelease);
  				}
  			});
  		}}
  		onPressOut={() => {
  			// Animate back to released state (value = 0) only if press animation completed or started
  			if (transition.value > 0 || isActive.value) {
  				transition.value = withTiming(0, timingConfigRelease);
  			}
  			isActive.value = false;
  		}}
  	>
  		{/* Container View manages the total height and holds both layers */}
  		<View style={styles.pressableContainer}>
  			{/* Shadow Layer (Static) */}
  			<View style={[styles.shadow, { backgroundColor: buttonShadowColor }]} />

  			{/* Button Content Layer (Animated) */}
  			<Animated.View style={[styles.buttonContent, animatedButtonStyle]}>
  				{isLoading ? (
  					<ActivityIndicator color={textColor} size={18} />
  				) : (
  					<>
  						{Icon}
  						<Text
  							numberOfLines={1}
  							style={[styles.title, { color: textColor }]}
  						>
  							{title}
  						</Text>
  					</>
  				)}
  			</Animated.View>
  		</View>
  	</Pressable>
  );
};

const styles = StyleSheet.create({
  // This View contains both the shadow and the animated button content
  // Its height accommodates the button + the visible shadow part
  pressableContainer: {
  	height: HEIGHT + SHADOW_HEIGHT,
  	width: "100%",
  },
  // The visible "base" or "shadow" of the 3D button
  shadow: {
  	borderRadius: BORDER_RADIUS,
  	height: HEIGHT,
  	width: "100%",
  	position: 'absolute', // Position it absolutely within the container
  	bottom: 0,          // Stick it to the bottom
  	left: 0,
  },
  // The main button content layer (the part that moves)
  buttonContent: {
  	alignItems: "center",
  	borderRadius: BORDER_RADIUS,
  	flexDirection: "row",
  	gap: 8,
  	height: HEIGHT,
  	justifyContent: "center",
  	paddingHorizontal: 12,
  	paddingVertical: 8,
  	width: "100%",
  	position: 'absolute',
  	left: 0,
  },
  title: {
  	flexShrink: 1,
  	fontSize: 18,
  	fontWeight: "600",
  },
});

Usage

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

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

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

return (
    <ThreeDimensionAnimatedButton
  	buttonColor={colors.PrimaryNormal}
  	textColor={colors.Neutral0}
  	buttonShadowColor={colors.PrimaryDisable}
  	onPress={() => {}}
  	title="3D Action"
  	reduceMotion="never"
  	Icon={<MaterialCommunityIcons name="human" size={18} color={colors.Neutral0} />}
  />
);}

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.