Skip to main content

Module 9: Animations and Gestures

Animation Fundamentals

Create fluid, performant animations that make your app feel alive and native

🎯 Learning Objectives

  • Understand why animation matters for mobile UX
  • Learn the difference between JS thread and UI thread animations
  • Master the React Native Animated API basics
  • Implement timing, spring, and decay animations
  • Compose and sequence multiple animations

Why Animation Matters

Animation isn't just decoration—it's a fundamental part of how users understand and interact with mobile interfaces. Well-crafted animations provide feedback, guide attention, and create a sense of direct manipulation that makes apps feel responsive and intuitive.

The Role of Animation in Mobile UX

flowchart TD
    subgraph Purpose["Animation Purposes"]
        A[🎯 Feedback]
        B[👁️ Focus Attention]
        C[🔗 Show Relationships]
        D[⏳ Indicate Progress]
        E[✨ Delight Users]
    end
    
    subgraph Examples["Examples"]
        A --> A1[Button press response]
        B --> B1[New item highlighting]
        C --> C1[Screen transitions]
        D --> D1[Loading spinners]
        E --> E1[Success celebrations]
    end
    
    style Purpose fill:#e3f2fd
    style Examples fill:#e8f5e9

Animation Guidelines

Principle Good Practice Avoid
Duration 200-500ms for most UI animations Animations longer than 1 second
Purpose Every animation serves a function Animation for decoration only
Easing Natural curves (ease-out, spring) Linear motion for UI elements
Consistency Same timing for similar actions Random durations throughout app
Performance 60fps smooth animations Janky, stuttering motion

💡 The 60fps Goal

Mobile screens typically refresh at 60 frames per second (60fps), giving you approximately 16.67ms per frame. To achieve smooth animations, each frame must be calculated and rendered within this time budget. When frames take longer, users perceive stuttering or "jank."

Animation Performance

Understanding how React Native handles animations is crucial for building performant apps. The key concept is the separation between the JavaScript thread and the UI (native) thread.

The Two-Thread Architecture

flowchart LR
    subgraph JS["JavaScript Thread"]
        A[React Logic]
        B[State Updates]
        C[Animation Calculations]
    end
    
    subgraph Bridge["Bridge"]
        D[Serialization]
        E[Async Messages]
    end
    
    subgraph UI["UI Thread (Native)"]
        F[Layout]
        G[Rendering]
        H[Touch Handling]
    end
    
    JS --> Bridge --> UI
    
    style JS fill:#fff3e0
    style Bridge fill:#ffebee
    style UI fill:#e8f5e9

Why This Matters for Animation

// ❌ Problem: Animation calculated on JS thread
// Every frame requires crossing the bridge
function BadAnimation() {
  const [position, setPosition] = useState(0);
  
  useEffect(() => {
    const interval = setInterval(() => {
      // This runs on JS thread
      // Then sends update across bridge
      // Then native re-renders
      setPosition(p => p + 1);
    }, 16);
    
    return () => clearInterval(interval);
  }, []);
  
  return (
    <View style={{ transform: [{ translateX: position }] }}>
      <Text>Moving...</Text>
    </View>
  );
}

// ✅ Solution: Use Animated API
// Animation runs entirely on native thread
import { Animated } from 'react-native';

function GoodAnimation() {
  const position = useRef(new Animated.Value(0)).current;
  
  useEffect(() => {
    Animated.timing(position, {
      toValue: 100,
      duration: 1000,
      useNativeDriver: true, // Key for performance!
    }).start();
  }, []);
  
  return (
    <Animated.View style={{ transform: [{ translateX: position }] }}>
      <Text>Moving smoothly!</Text>
    </Animated.View>
  );
}

Native Driver

The useNativeDriver option is critical for performance. When enabled, the animation configuration is sent to the native side once at the start, and all subsequent frame calculations happen on the UI thread without crossing the bridge.

Property Native Driver Support
transform (translateX, translateY, scale, rotate) ✅ Yes
opacity ✅ Yes
width, height ❌ No (causes layout)
backgroundColor ❌ No
margin, padding ❌ No (causes layout)

⚠️ Native Driver Limitations

When you need to animate properties that don't support native driver (like width, height, or colors), you have two options:

  • Use useNativeDriver: false and accept potential performance issues
  • Use React Native Reanimated (covered in the next lesson) which supports more properties natively

The Animated API

React Native's built-in Animated API provides everything you need for basic animations. It's declarative, composable, and when used correctly, highly performant.

Core Concepts

flowchart TD
    subgraph Values["Animated Values"]
        A[Animated.Value]
        B[Animated.ValueXY]
    end
    
    subgraph Drivers["Animation Drivers"]
        C[Animated.timing]
        D[Animated.spring]
        E[Animated.decay]
    end
    
    subgraph Components["Animated Components"]
        F[Animated.View]
        G[Animated.Text]
        H[Animated.Image]
        I[Animated.ScrollView]
    end
    
    subgraph Composition["Composition"]
        J[Animated.parallel]
        K[Animated.sequence]
        L[Animated.stagger]
    end
    
    Values --> Drivers
    Drivers --> Components
    Drivers --> Composition
    
    style Values fill:#e3f2fd
    style Drivers fill:#fff3e0
    style Components fill:#e8f5e9
    style Composition fill:#fce4ec

Basic Setup Pattern

import React, { useRef, useEffect } from 'react';
import { Animated, View, StyleSheet } from 'react-native';

function BasicAnimation() {
  // 1. Create an animated value
  const fadeAnim = useRef(new Animated.Value(0)).current;
  
  useEffect(() => {
    // 2. Define and start the animation
    Animated.timing(fadeAnim, {
      toValue: 1,
      duration: 1000,
      useNativeDriver: true,
    }).start();
  }, [fadeAnim]);
  
  // 3. Use the animated value in an Animated component
  return (
    <Animated.View style={[styles.box, { opacity: fadeAnim }]}>
      <Text>I fade in!</Text>
    </Animated.View>
  );
}

const styles = StyleSheet.create({
  box: {
    width: 100,
    height: 100,
    backgroundColor: '#007AFF',
    justifyContent: 'center',
    alignItems: 'center',
  },
});

Animated Components

import { Animated } from 'react-native';

// Built-in animated components
<Animated.View />      // Most common
<Animated.Text />      // For text animations
<Animated.Image />     // For image animations
<Animated.ScrollView /> // For scroll-linked animations
<Animated.FlatList />  // For list animations

// Creating custom animated components
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
const AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient);

// Usage
function CustomAnimatedComponent() {
  const scale = useRef(new Animated.Value(1)).current;
  
  return (
    <AnimatedPressable
      style={{ transform: [{ scale }] }}
      onPressIn={() => {
        Animated.spring(scale, {
          toValue: 0.95,
          useNativeDriver: true,
        }).start();
      }}
      onPressOut={() => {
        Animated.spring(scale, {
          toValue: 1,
          useNativeDriver: true,
        }).start();
      }}
    >
      <Text>Press me!</Text>
    </AnimatedPressable>
  );
}

Animated Values

Animated values are the foundation of the Animated API. They hold the current state of an animation and can be interpolated, combined, and connected to component styles.

Animated.Value

import React, { useRef } from 'react';
import { Animated } from 'react-native';

function AnimatedValueExample() {
  // Create a single animated value
  const opacity = useRef(new Animated.Value(0)).current;
  const scale = useRef(new Animated.Value(0.5)).current;
  const rotation = useRef(new Animated.Value(0)).current;
  
  // Values can be set directly (no animation)
  const resetValues = () => {
    opacity.setValue(0);
    scale.setValue(0.5);
    rotation.setValue(0);
  };
  
  // Get the current value (for debugging/logic)
  const logCurrentOpacity = () => {
    // Note: This is async and may not reflect the exact current value
    opacity.addListener(({ value }) => {
      console.log('Current opacity:', value);
    });
  };
  
  return (
    <Animated.View
      style={{
        opacity: opacity,
        transform: [
          { scale: scale },
          { rotate: rotation.interpolate({
              inputRange: [0, 1],
              outputRange: ['0deg', '360deg'],
            })
          },
        ],
      }}
    />
  );
}

Animated.ValueXY

import React, { useRef } from 'react';
import { Animated, PanResponder } from 'react-native';

function DraggableBox() {
  // Create a 2D animated value for position
  const position = useRef(new Animated.ValueXY({ x: 0, y: 0 })).current;
  
  // ValueXY provides helpful methods
  const panResponder = useRef(
    PanResponder.create({
      onStartShouldSetPanResponder: () => true,
      onPanResponderMove: Animated.event(
        [null, { dx: position.x, dy: position.y }],
        { useNativeDriver: false }
      ),
      onPanResponderRelease: () => {
        // Spring back to origin
        Animated.spring(position, {
          toValue: { x: 0, y: 0 },
          useNativeDriver: true,
        }).start();
      },
    })
  ).current;
  
  return (
    <Animated.View
      {...panResponder.panHandlers}
      style={[
        styles.box,
        // getLayout() returns { left, top } style object
        position.getLayout(),
        // Or use getTranslateTransform() for transforms
        // { transform: position.getTranslateTransform() }
      ]}
    />
  );
}

// ValueXY methods
const position = new Animated.ValueXY({ x: 0, y: 0 });

// Get as layout style (left, top)
position.getLayout(); // { left: x, top: y }

// Get as transform style
position.getTranslateTransform(); // [{ translateX: x }, { translateY: y }]

// Set both values at once
position.setValue({ x: 100, y: 200 });

// Reset to initial value
position.setOffset({ x: 0, y: 0 });

// Extract individual values
const { x, y } = position;
// x and y are Animated.Value instances

Interpolation

Interpolation is one of the most powerful features of the Animated API. It allows you to map an animated value to different output ranges and types.

import React, { useRef, useEffect } from 'react';
import { Animated, StyleSheet, View } from 'react-native';

function InterpolationExample() {
  const progress = useRef(new Animated.Value(0)).current;
  
  useEffect(() => {
    Animated.timing(progress, {
      toValue: 1,
      duration: 2000,
      useNativeDriver: true,
    }).start();
  }, []);
  
  // Basic interpolation: map 0-1 to 0-100
  const translateX = progress.interpolate({
    inputRange: [0, 1],
    outputRange: [0, 100],
  });
  
  // Non-linear interpolation with multiple stops
  const scale = progress.interpolate({
    inputRange: [0, 0.5, 1],
    outputRange: [1, 1.5, 1],  // Scale up then back down
  });
  
  // String interpolation (for rotation, colors)
  const rotate = progress.interpolate({
    inputRange: [0, 1],
    outputRange: ['0deg', '360deg'],
  });
  
  // Color interpolation (note: requires useNativeDriver: false)
  const backgroundColor = progress.interpolate({
    inputRange: [0, 0.5, 1],
    outputRange: ['#FF0000', '#00FF00', '#0000FF'],
  });
  
  // Clamping behavior
  const clampedValue = progress.interpolate({
    inputRange: [0, 1],
    outputRange: [0, 100],
    extrapolate: 'clamp',        // Clamp both ends
    // extrapolateLeft: 'clamp', // Clamp only left
    // extrapolateRight: 'extend', // Extend only right
  });
  
  return (
    <View style={styles.container}>
      <Animated.View
        style={[
          styles.box,
          {
            transform: [
              { translateX },
              { scale },
              { rotate },
            ],
          },
        ]}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
  box: {
    width: 100,
    height: 100,
    backgroundColor: '#007AFF',
  },
});

Common Interpolation Patterns

// Fade in while sliding up
const opacity = animation.interpolate({
  inputRange: [0, 1],
  outputRange: [0, 1],
});
const translateY = animation.interpolate({
  inputRange: [0, 1],
  outputRange: [50, 0],
});

// Bounce effect (overshoot)
const scale = animation.interpolate({
  inputRange: [0, 0.5, 0.75, 1],
  outputRange: [0, 1.2, 0.9, 1],
});

// Shake animation
const shake = animation.interpolate({
  inputRange: [0, 0.25, 0.5, 0.75, 1],
  outputRange: ['0deg', '-5deg', '5deg', '-5deg', '0deg'],
});

// Progress bar fill
const width = progress.interpolate({
  inputRange: [0, 1],
  outputRange: ['0%', '100%'],
});

// Scroll-linked parallax
const parallaxTranslate = scrollY.interpolate({
  inputRange: [-100, 0, 100],
  outputRange: [50, 0, -50],
  extrapolate: 'clamp',
});

💡 Interpolation Tips

  • inputRange values must be monotonically increasing
  • Use extrapolate: 'clamp' to prevent values outside the range
  • String outputs (degrees, colors, percentages) require useNativeDriver: false except for rotation
  • Chain interpolations for complex mappings

Timing Animations

Animated.timing() creates animations that progress from one value to another over a specified duration, optionally with an easing function.

Basic Timing Animation

import React, { useRef } from 'react';
import { Animated, Pressable, Text, StyleSheet } from 'react-native';

function TimingExample() {
  const fadeAnim = useRef(new Animated.Value(0)).current;
  
  const fadeIn = () => {
    Animated.timing(fadeAnim, {
      toValue: 1,
      duration: 500,
      useNativeDriver: true,
    }).start();
  };
  
  const fadeOut = () => {
    Animated.timing(fadeAnim, {
      toValue: 0,
      duration: 500,
      useNativeDriver: true,
    }).start();
  };
  
  return (
    <View style={styles.container}>
      <Animated.View style={[styles.box, { opacity: fadeAnim }]} />
      
      <Pressable style={styles.button} onPress={fadeIn}>
        <Text style={styles.buttonText}>Fade In</Text>
      </Pressable>
      
      <Pressable style={styles.button} onPress={fadeOut}>
        <Text style={styles.buttonText}>Fade Out</Text>
      </Pressable>
    </View>
  );
}

Easing Functions

Easing functions control the rate of change over time, making animations feel more natural.

import { Animated, Easing } from 'react-native';

// Linear (constant speed - usually feels unnatural)
Animated.timing(value, {
  toValue: 1,
  duration: 500,
  easing: Easing.linear,
  useNativeDriver: true,
}).start();

// Ease in (starts slow, ends fast)
Animated.timing(value, {
  toValue: 1,
  duration: 500,
  easing: Easing.in(Easing.ease),
  useNativeDriver: true,
}).start();

// Ease out (starts fast, ends slow) - Most common for UI
Animated.timing(value, {
  toValue: 1,
  duration: 500,
  easing: Easing.out(Easing.ease),
  useNativeDriver: true,
}).start();

// Ease in-out (slow start and end)
Animated.timing(value, {
  toValue: 1,
  duration: 500,
  easing: Easing.inOut(Easing.ease),
  useNativeDriver: true,
}).start();

// Bounce effect
Animated.timing(value, {
  toValue: 1,
  duration: 800,
  easing: Easing.bounce,
  useNativeDriver: true,
}).start();

// Elastic effect
Animated.timing(value, {
  toValue: 1,
  duration: 1000,
  easing: Easing.elastic(2),
  useNativeDriver: true,
}).start();

// Back (overshoots then returns)
Animated.timing(value, {
  toValue: 1,
  duration: 500,
  easing: Easing.back(1.5),
  useNativeDriver: true,
}).start();

// Bezier curve (custom easing)
Animated.timing(value, {
  toValue: 1,
  duration: 500,
  easing: Easing.bezier(0.25, 0.1, 0.25, 1),
  useNativeDriver: true,
}).start();

Easing Visualization

Time Value Linear Ease Out Ease In Bounce

Animation Callbacks

import { Animated } from 'react-native';

function AnimationCallbacks() {
  const value = useRef(new Animated.Value(0)).current;
  
  const runAnimation = () => {
    Animated.timing(value, {
      toValue: 1,
      duration: 500,
      useNativeDriver: true,
    }).start(({ finished }) => {
      // Callback when animation completes
      if (finished) {
        console.log('Animation completed successfully');
        // Chain another animation
        Animated.timing(value, {
          toValue: 0,
          duration: 500,
          useNativeDriver: true,
        }).start();
      } else {
        console.log('Animation was interrupted');
      }
    });
  };
  
  // Stop animation programmatically
  const stopAnimation = () => {
    value.stopAnimation((currentValue) => {
      console.log('Stopped at value:', currentValue);
    });
  };
  
  // Reset to initial value
  const resetAnimation = () => {
    value.setValue(0);
  };
  
  return (/* ... */);
}

Looping Animations

import { Animated, Easing } from 'react-native';

function LoopingAnimation() {
  const rotation = useRef(new Animated.Value(0)).current;
  const pulse = useRef(new Animated.Value(1)).current;
  
  useEffect(() => {
    // Infinite rotation
    Animated.loop(
      Animated.timing(rotation, {
        toValue: 1,
        duration: 2000,
        easing: Easing.linear,
        useNativeDriver: true,
      })
    ).start();
    
    // Pulsing animation (loop with reverse)
    Animated.loop(
      Animated.sequence([
        Animated.timing(pulse, {
          toValue: 1.2,
          duration: 500,
          useNativeDriver: true,
        }),
        Animated.timing(pulse, {
          toValue: 1,
          duration: 500,
          useNativeDriver: true,
        }),
      ])
    ).start();
  }, []);
  
  const rotate = rotation.interpolate({
    inputRange: [0, 1],
    outputRange: ['0deg', '360deg'],
  });
  
  return (
    <View style={styles.container}>
      {/* Spinning loader */}
      <Animated.View
        style={[
          styles.spinner,
          { transform: [{ rotate }] },
        ]}
      />
      
      {/* Pulsing dot */}
      <Animated.View
        style={[
          styles.dot,
          { transform: [{ scale: pulse }] },
        ]}
      />
    </View>
  );
}

// Loop with iteration count
Animated.loop(
  Animated.timing(value, { /* ... */ }),
  { iterations: 3 }  // Run 3 times then stop
).start();

Spring Animations

Animated.spring() creates physics-based animations that feel natural and responsive. Springs are ideal for interactions like dragging, releasing, and button presses because they respond dynamically to their target value.

Basic Spring Animation

import React, { useRef } from 'react';
import { Animated, Pressable, StyleSheet, View } from 'react-native';

function SpringExample() {
  const scale = useRef(new Animated.Value(1)).current;
  
  const onPressIn = () => {
    Animated.spring(scale, {
      toValue: 0.9,
      useNativeDriver: true,
    }).start();
  };
  
  const onPressOut = () => {
    Animated.spring(scale, {
      toValue: 1,
      useNativeDriver: true,
    }).start();
  };
  
  return (
    <Pressable onPressIn={onPressIn} onPressOut={onPressOut}>
      <Animated.View
        style={[
          styles.button,
          { transform: [{ scale }] },
        ]}
      >
        <Text style={styles.buttonText}>Press Me</Text>
      </Animated.View>
    </Pressable>
  );
}

Spring Configuration

Springs can be configured using either the bounciness/speed model or the tension/friction model.

import { Animated } from 'react-native';

// Bounciness and Speed model (simpler)
Animated.spring(value, {
  toValue: 1,
  speed: 12,       // Controls animation speed (default: 12)
  bounciness: 8,   // Controls bounciness (default: 8)
  useNativeDriver: true,
}).start();

// Tension and Friction model (more control)
Animated.spring(value, {
  toValue: 1,
  tension: 40,     // Controls the spring's stiffness (default: 40)
  friction: 7,     // Controls the damping/resistance (default: 7)
  useNativeDriver: true,
}).start();

// Stiffness, Damping, Mass model (physics-based)
Animated.spring(value, {
  toValue: 1,
  stiffness: 100,  // Spring stiffness coefficient
  damping: 10,     // Damping coefficient
  mass: 1,         // Mass of the object (default: 1)
  useNativeDriver: true,
}).start();

// Additional options
Animated.spring(value, {
  toValue: 1,
  velocity: 0.5,           // Initial velocity
  restDisplacementThreshold: 0.001, // When to consider at rest
  restSpeedThreshold: 0.001,        // Speed to consider stopped
  useNativeDriver: true,
}).start();

Spring Presets

// Common spring configurations
const SpringPresets = {
  // Gentle, slow spring (for large movements)
  gentle: {
    tension: 20,
    friction: 7,
  },
  
  // Default feel
  default: {
    tension: 40,
    friction: 7,
  },
  
  // Snappy response (for button presses)
  snappy: {
    tension: 100,
    friction: 10,
  },
  
  // Very bouncy (for playful UI)
  bouncy: {
    tension: 80,
    friction: 3,
  },
  
  // Stiff, minimal bounce (for precise control)
  stiff: {
    tension: 200,
    friction: 20,
  },
  
  // Slow and smooth (for page transitions)
  smooth: {
    tension: 50,
    friction: 12,
  },
};

// Usage
function AnimatedButton() {
  const scale = useRef(new Animated.Value(1)).current;
  
  const onPress = () => {
    Animated.spring(scale, {
      toValue: 0.95,
      ...SpringPresets.snappy,
      useNativeDriver: true,
    }).start(() => {
      Animated.spring(scale, {
        toValue: 1,
        ...SpringPresets.snappy,
        useNativeDriver: true,
      }).start();
    });
  };
  
  return (
    <Pressable onPress={onPress}>
      <Animated.View style={{ transform: [{ scale }] }}>
        <Text>Tap</Text>
      </Animated.View>
    </Pressable>
  );
}

Spring vs Timing Comparison

Aspect Timing Spring
Duration Fixed, specified in ms Variable, depends on physics
Best for Fades, progress bars, timed sequences Interactive UI, drag releases, buttons
Interruptible Restarts from current position Continues naturally with velocity
Feel Mechanical, predictable Organic, responsive

💡 When to Use Spring

  • Interactive elements: Buttons, toggles, cards that respond to touch
  • Drag and release: When something is thrown and needs to settle
  • Target changes mid-animation: Spring naturally adjusts to new targets
  • When duration doesn't matter: Let physics determine timing

Decay Animations

Animated.decay() creates animations that start with an initial velocity and gradually slow down based on a deceleration factor. This is perfect for momentum-based scrolling and flick gestures.

Basic Decay Animation

import React, { useRef } from 'react';
import { Animated, PanResponder, StyleSheet, View } from 'react-native';

function DecayExample() {
  const position = useRef(new Animated.ValueXY()).current;
  
  const panResponder = useRef(
    PanResponder.create({
      onStartShouldSetPanResponder: () => true,
      onPanResponderMove: Animated.event(
        [null, { dx: position.x, dy: position.y }],
        { useNativeDriver: false }
      ),
      onPanResponderRelease: (_, gestureState) => {
        // Decay animation based on release velocity
        Animated.decay(position, {
          velocity: { 
            x: gestureState.vx, 
            y: gestureState.vy 
          },
          deceleration: 0.997, // 0.998 is default, lower = faster stop
          useNativeDriver: true,
        }).start();
      },
    })
  ).current;
  
  return (
    <View style={styles.container}>
      <Animated.View
        {...panResponder.panHandlers}
        style={[
          styles.ball,
          { transform: position.getTranslateTransform() },
        ]}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#f0f0f0',
  },
  ball: {
    width: 80,
    height: 80,
    borderRadius: 40,
    backgroundColor: '#007AFF',
  },
});

Decay Configuration

Animated.decay(value, {
  velocity: 0.5,         // Initial velocity (required)
  deceleration: 0.997,   // Rate of deceleration (default: 0.997)
  useNativeDriver: true,
}).start();

// Deceleration values:
// 0.999 - Very slow deceleration (long coast)
// 0.997 - Default, moderate deceleration
// 0.990 - Fast deceleration (quick stop)

// For 2D decay
Animated.decay(positionXY, {
  velocity: { x: velocityX, y: velocityY },
  deceleration: 0.997,
  useNativeDriver: true,
}).start();

Decay with Boundaries

import React, { useRef } from 'react';
import { Animated, PanResponder, Dimensions } from 'react-native';

const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
const BALL_SIZE = 80;

function BoundedDecay() {
  const position = useRef(new Animated.ValueXY()).current;
  
  const clampPosition = () => {
    // Get current values
    const currentX = position.x._value;
    const currentY = position.y._value;
    
    // Check boundaries
    const maxX = SCREEN_WIDTH - BALL_SIZE;
    const maxY = SCREEN_HEIGHT - BALL_SIZE;
    
    let needsCorrection = false;
    let targetX = currentX;
    let targetY = currentY;
    
    if (currentX < 0) { targetX = 0; needsCorrection = true; }
    if (currentX > maxX) { targetX = maxX; needsCorrection = true; }
    if (currentY < 0) { targetY = 0; needsCorrection = true; }
    if (currentY > maxY) { targetY = maxY; needsCorrection = true; }
    
    if (needsCorrection) {
      Animated.spring(position, {
        toValue: { x: targetX, y: targetY },
        useNativeDriver: true,
        tension: 100,
        friction: 10,
      }).start();
    }
  };
  
  const panResponder = useRef(
    PanResponder.create({
      onStartShouldSetPanResponder: () => true,
      onPanResponderGrant: () => {
        // Stop any ongoing animation
        position.stopAnimation();
        position.setOffset({
          x: position.x._value,
          y: position.y._value,
        });
        position.setValue({ x: 0, y: 0 });
      },
      onPanResponderMove: Animated.event(
        [null, { dx: position.x, dy: position.y }],
        { useNativeDriver: false }
      ),
      onPanResponderRelease: (_, gestureState) => {
        position.flattenOffset();
        
        Animated.decay(position, {
          velocity: { x: gestureState.vx, y: gestureState.vy },
          deceleration: 0.997,
          useNativeDriver: true,
        }).start(() => {
          // Check boundaries after decay completes
          clampPosition();
        });
      },
    })
  ).current;
  
  return (
    <Animated.View
      {...panResponder.panHandlers}
      style={[
        styles.ball,
        { transform: position.getTranslateTransform() },
      ]}
    />
  );
}

When to Use Each Animation Type

flowchart TD
    A[Need Animation] --> B{What triggers it?}
    
    B -->|Time-based event| C{Fixed duration needed?}
    B -->|User interaction| D{Type of interaction?}
    
    C -->|Yes| E[Use Timing]
    C -->|No| F[Use Spring]
    
    D -->|Button press/tap| F
    D -->|Drag and release| G{With momentum?}
    D -->|Scroll-linked| H[Use Interpolation]
    
    G -->|Yes| I[Use Decay]
    G -->|No| F
    
    style E fill:#fff3e0
    style F fill:#e3f2fd
    style I fill:#e8f5e9
    style H fill:#fce4ec

Composing Animations

Complex animations often require multiple values animating together or in sequence. The Animated API provides several methods for composing animations.

Parallel Animations

Run multiple animations at the same time.

import { Animated } from 'react-native';

function ParallelExample() {
  const opacity = useRef(new Animated.Value(0)).current;
  const translateY = useRef(new Animated.Value(50)).current;
  const scale = useRef(new Animated.Value(0.8)).current;
  
  const animateIn = () => {
    // Reset values
    opacity.setValue(0);
    translateY.setValue(50);
    scale.setValue(0.8);
    
    // Run all animations simultaneously
    Animated.parallel([
      Animated.timing(opacity, {
        toValue: 1,
        duration: 300,
        useNativeDriver: true,
      }),
      Animated.timing(translateY, {
        toValue: 0,
        duration: 300,
        useNativeDriver: true,
      }),
      Animated.spring(scale, {
        toValue: 1,
        tension: 50,
        friction: 7,
        useNativeDriver: true,
      }),
    ]).start();
  };
  
  return (
    <Animated.View
      style={{
        opacity,
        transform: [
          { translateY },
          { scale },
        ],
      }}
    >
      <Text>Animated Content</Text>
    </Animated.View>
  );
}

// With stopTogether option
Animated.parallel(animations, {
  stopTogether: false, // If one stops, others continue (default: true)
}).start();

Sequence Animations

Run animations one after another.

import { Animated } from 'react-native';

function SequenceExample() {
  const step1 = useRef(new Animated.Value(0)).current;
  const step2 = useRef(new Animated.Value(0)).current;
  const step3 = useRef(new Animated.Value(0)).current;
  
  const runSequence = () => {
    // Reset
    step1.setValue(0);
    step2.setValue(0);
    step3.setValue(0);
    
    // Run in order
    Animated.sequence([
      Animated.timing(step1, {
        toValue: 1,
        duration: 300,
        useNativeDriver: true,
      }),
      Animated.timing(step2, {
        toValue: 1,
        duration: 300,
        useNativeDriver: true,
      }),
      Animated.timing(step3, {
        toValue: 1,
        duration: 300,
        useNativeDriver: true,
      }),
    ]).start(() => {
      console.log('All steps complete!');
    });
  };
  
  return (/* ... */);
}

Stagger Animations

Start multiple animations with a delay between each.

import { Animated } from 'react-native';

function StaggerExample() {
  // Array of animated values for list items
  const items = useRef(
    [0, 1, 2, 3, 4].map(() => new Animated.Value(0))
  ).current;
  
  const animateItems = () => {
    // Reset all
    items.forEach(item => item.setValue(0));
    
    // Stagger animations with 100ms delay between each
    Animated.stagger(100, 
      items.map(item =>
        Animated.spring(item, {
          toValue: 1,
          tension: 50,
          friction: 7,
          useNativeDriver: true,
        })
      )
    ).start();
  };
  
  return (
    <View>
      {items.map((animValue, index) => (
        <Animated.View
          key={index}
          style={{
            opacity: animValue,
            transform: [{
              translateX: animValue.interpolate({
                inputRange: [0, 1],
                outputRange: [-100, 0],
              }),
            }],
          }}
        >
          <Text>Item {index + 1}</Text>
        </Animated.View>
      ))}
    </View>
  );
}

Delay

import { Animated } from 'react-native';

// Add delay before an animation
Animated.sequence([
  Animated.delay(500), // Wait 500ms
  Animated.timing(value, {
    toValue: 1,
    duration: 300,
    useNativeDriver: true,
  }),
]).start();

// Delay between animations
Animated.sequence([
  Animated.timing(fadeIn, { /* ... */ }),
  Animated.delay(200),
  Animated.timing(slideUp, { /* ... */ }),
]).start();

Complex Composition Example

import React, { useRef } from 'react';
import { Animated, Easing, StyleSheet, View, Pressable, Text } from 'react-native';

function ComplexAnimation() {
  const fadeAnim = useRef(new Animated.Value(0)).current;
  const slideAnim = useRef(new Animated.Value(-100)).current;
  const scaleAnim = useRef(new Animated.Value(0)).current;
  const rotateAnim = useRef(new Animated.Value(0)).current;
  
  const playAnimation = () => {
    // Reset all values
    fadeAnim.setValue(0);
    slideAnim.setValue(-100);
    scaleAnim.setValue(0);
    rotateAnim.setValue(0);
    
    Animated.sequence([
      // First: Fade in and slide simultaneously
      Animated.parallel([
        Animated.timing(fadeAnim, {
          toValue: 1,
          duration: 300,
          useNativeDriver: true,
        }),
        Animated.timing(slideAnim, {
          toValue: 0,
          duration: 300,
          easing: Easing.out(Easing.back(1.5)),
          useNativeDriver: true,
        }),
      ]),
      
      // Then: Scale up with bounce
      Animated.spring(scaleAnim, {
        toValue: 1,
        tension: 80,
        friction: 4,
        useNativeDriver: true,
      }),
      
      // Small delay
      Animated.delay(100),
      
      // Finally: Spin
      Animated.timing(rotateAnim, {
        toValue: 1,
        duration: 500,
        easing: Easing.elastic(1),
        useNativeDriver: true,
      }),
    ]).start();
  };
  
  const rotate = rotateAnim.interpolate({
    inputRange: [0, 1],
    outputRange: ['0deg', '360deg'],
  });
  
  return (
    <View style={styles.container}>
      <Animated.View
        style={[
          styles.box,
          {
            opacity: fadeAnim,
            transform: [
              { translateY: slideAnim },
              { scale: scaleAnim },
              { rotate },
            ],
          },
        ]}
      >
        <Text style={styles.boxText}>✨</Text>
      </Animated.View>
      
      <Pressable style={styles.button} onPress={playAnimation}>
        <Text style={styles.buttonText}>Play Animation</Text>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
  box: {
    width: 120,
    height: 120,
    backgroundColor: '#007AFF',
    borderRadius: 20,
    justifyContent: 'center',
    alignItems: 'center',
    marginBottom: 40,
  },
  boxText: {
    fontSize: 48,
  },
  button: {
    backgroundColor: '#34C759',
    paddingVertical: 12,
    paddingHorizontal: 24,
    borderRadius: 8,
  },
  buttonText: {
    color: 'white',
    fontSize: 16,
    fontWeight: '600',
  },
});

⚠️ Animation Composition Tips

  • Always reset animated values before replaying an animation
  • Use stopAnimation() to prevent animation buildup
  • Consider extracting common animation patterns into reusable functions
  • Test on real devices—simulators may not reveal performance issues

Hands-On Exercises

Exercise 1: Animated Like Button

Create a heart-shaped like button with satisfying animation feedback when pressed.

Requirements:

  • Scale down on press, bounce back on release
  • Color transition from gray to red when liked
  • Small hearts burst effect when liking
  • Toggle between liked and unliked states
Show Solution
import React, { useRef, useState } from 'react';
import { Animated, Pressable, StyleSheet, View, Text } from 'react-native';

function AnimatedLikeButton() {
  const [isLiked, setIsLiked] = useState(false);
  
  const scaleAnim = useRef(new Animated.Value(1)).current;
  const burstAnims = useRef(
    Array(6).fill(0).map(() => ({
      scale: new Animated.Value(0),
      opacity: new Animated.Value(1),
      translateX: new Animated.Value(0),
      translateY: new Animated.Value(0),
    }))
  ).current;
  
  const handlePressIn = () => {
    Animated.spring(scaleAnim, {
      toValue: 0.8,
      tension: 100,
      friction: 5,
      useNativeDriver: true,
    }).start();
  };
  
  const handlePressOut = () => {
    Animated.spring(scaleAnim, {
      toValue: 1,
      tension: 100,
      friction: 5,
      useNativeDriver: true,
    }).start();
  };
  
  const handlePress = () => {
    const newLikedState = !isLiked;
    setIsLiked(newLikedState);
    
    if (newLikedState) {
      // Burst animation when liking
      burstAnims.forEach((anim, index) => {
        anim.scale.setValue(0);
        anim.opacity.setValue(1);
        anim.translateX.setValue(0);
        anim.translateY.setValue(0);
        
        const angle = (index / 6) * 2 * Math.PI;
        const distance = 40;
        const targetX = Math.cos(angle) * distance;
        const targetY = Math.sin(angle) * distance;
        
        Animated.parallel([
          Animated.sequence([
            Animated.spring(anim.scale, {
              toValue: 1,
              tension: 200,
              friction: 5,
              useNativeDriver: true,
            }),
            Animated.timing(anim.scale, {
              toValue: 0,
              duration: 200,
              useNativeDriver: true,
            }),
          ]),
          Animated.timing(anim.translateX, {
            toValue: targetX,
            duration: 400,
            useNativeDriver: true,
          }),
          Animated.timing(anim.translateY, {
            toValue: targetY,
            duration: 400,
            useNativeDriver: true,
          }),
          Animated.timing(anim.opacity, {
            toValue: 0,
            duration: 400,
            useNativeDriver: true,
          }),
        ]).start();
      });
      
      // Main heart bounce
      Animated.sequence([
        Animated.spring(scaleAnim, {
          toValue: 1.3,
          tension: 200,
          friction: 3,
          useNativeDriver: true,
        }),
        Animated.spring(scaleAnim, {
          toValue: 1,
          tension: 100,
          friction: 5,
          useNativeDriver: true,
        }),
      ]).start();
    }
  };
  
  return (
    <View style={styles.container}>
      {/* Burst particles */}
      {burstAnims.map((anim, index) => (
        <Animated.Text
          key={index}
          style={[
            styles.particle,
            {
              opacity: anim.opacity,
              transform: [
                { scale: anim.scale },
                { translateX: anim.translateX },
                { translateY: anim.translateY },
              ],
            },
          ]}
        >
          ❤️
        </Animated.Text>
      ))}
      
      {/* Main button */}
      <Pressable
        onPressIn={handlePressIn}
        onPressOut={handlePressOut}
        onPress={handlePress}
      >
        <Animated.View
          style={[
            styles.button,
            { transform: [{ scale: scaleAnim }] },
          ]}
        >
          <Text style={styles.heart}>
            {isLiked ? '❤️' : '🤍'}
          </Text>
        </Animated.View>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    alignItems: 'center',
    justifyContent: 'center',
    height: 150,
  },
  button: {
    width: 80,
    height: 80,
    borderRadius: 40,
    backgroundColor: '#f0f0f0',
    alignItems: 'center',
    justifyContent: 'center',
  },
  heart: {
    fontSize: 36,
  },
  particle: {
    position: 'absolute',
    fontSize: 16,
  },
});

Exercise 2: Staggered List Animation

Create an animated list where items slide in one after another when the screen loads.

Requirements:

  • Items slide in from the right with fade
  • Each item starts 100ms after the previous
  • Use spring animation for natural feel
  • Add a "Refresh" button that replays the animation
Show Solution
import React, { useRef, useEffect } from 'react';
import { 
  Animated, View, Text, Pressable, 
  StyleSheet, FlatList 
} from 'react-native';

const ITEMS = [
  { id: '1', title: 'First Item', subtitle: 'Welcome to the list' },
  { id: '2', title: 'Second Item', subtitle: 'This slides in after' },
  { id: '3', title: 'Third Item', subtitle: 'Staggered animation' },
  { id: '4', title: 'Fourth Item', subtitle: 'Looks pretty cool' },
  { id: '5', title: 'Fifth Item', subtitle: 'Spring physics' },
  { id: '6', title: 'Sixth Item', subtitle: 'Last one!' },
];

function StaggeredList() {
  const animatedValues = useRef(
    ITEMS.map(() => new Animated.Value(0))
  ).current;
  
  const animateIn = () => {
    // Reset all values
    animatedValues.forEach(anim => anim.setValue(0));
    
    // Stagger animations
    Animated.stagger(
      100,
      animatedValues.map(anim =>
        Animated.spring(anim, {
          toValue: 1,
          tension: 50,
          friction: 8,
          useNativeDriver: true,
        })
      )
    ).start();
  };
  
  useEffect(() => {
    // Animate on mount
    const timer = setTimeout(animateIn, 300);
    return () => clearTimeout(timer);
  }, []);
  
  const renderItem = ({ item, index }: { item: typeof ITEMS[0]; index: number }) => {
    const animValue = animatedValues[index];
    
    const translateX = animValue.interpolate({
      inputRange: [0, 1],
      outputRange: [100, 0],
    });
    
    const opacity = animValue.interpolate({
      inputRange: [0, 0.5, 1],
      outputRange: [0, 0.5, 1],
    });
    
    return (
      <Animated.View
        style={[
          styles.itemContainer,
          {
            opacity,
            transform: [{ translateX }],
          },
        ]}
      >
        <View style={styles.item}>
          <Text style={styles.itemTitle}>{item.title}</Text>
          <Text style={styles.itemSubtitle}>{item.subtitle}</Text>
        </View>
      </Animated.View>
    );
  };
  
  return (
    <View style={styles.container}>
      <Pressable style={styles.refreshButton} onPress={animateIn}>
        <Text style={styles.refreshText}>🔄 Refresh Animation</Text>
      </Pressable>
      
      <FlatList
        data={ITEMS}
        renderItem={renderItem}
        keyExtractor={item => item.id}
        contentContainerStyle={styles.list}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#f5f5f5',
  },
  refreshButton: {
    backgroundColor: '#007AFF',
    padding: 16,
    margin: 16,
    borderRadius: 12,
    alignItems: 'center',
  },
  refreshText: {
    color: 'white',
    fontSize: 16,
    fontWeight: '600',
  },
  list: {
    padding: 16,
  },
  itemContainer: {
    marginBottom: 12,
  },
  item: {
    backgroundColor: 'white',
    padding: 16,
    borderRadius: 12,
    shadowColor: '#000',
    shadowOffset: { width: 0, height: 2 },
    shadowOpacity: 0.1,
    shadowRadius: 4,
    elevation: 3,
  },
  itemTitle: {
    fontSize: 18,
    fontWeight: '600',
    marginBottom: 4,
  },
  itemSubtitle: {
    fontSize: 14,
    color: '#666',
  },
});

Exercise 3: Animated Progress Ring

Create a circular progress indicator that animates smoothly.

Requirements:

  • Circular progress ring using SVG or transforms
  • Animate from 0 to target percentage
  • Display percentage text in center
  • Smooth timing animation with ease-out
Show Solution
import React, { useRef, useEffect, useState } from 'react';
import { Animated, View, Text, StyleSheet, Easing } from 'react-native';
import Svg, { Circle } from 'react-native-svg';

const AnimatedCircle = Animated.createAnimatedComponent(Circle);

interface ProgressRingProps {
  progress: number; // 0 to 100
  size?: number;
  strokeWidth?: number;
  color?: string;
}

function ProgressRing({
  progress,
  size = 120,
  strokeWidth = 10,
  color = '#007AFF',
}: ProgressRingProps) {
  const animatedProgress = useRef(new Animated.Value(0)).current;
  const [displayProgress, setDisplayProgress] = useState(0);
  
  const radius = (size - strokeWidth) / 2;
  const circumference = 2 * Math.PI * radius;
  
  useEffect(() => {
    // Animate to new progress value
    Animated.timing(animatedProgress, {
      toValue: progress,
      duration: 1000,
      easing: Easing.out(Easing.ease),
      useNativeDriver: false, // Can't use native driver for strokeDashoffset
    }).start();
    
    // Update display value with listener
    const listenerId = animatedProgress.addListener(({ value }) => {
      setDisplayProgress(Math.round(value));
    });
    
    return () => {
      animatedProgress.removeListener(listenerId);
    };
  }, [progress]);
  
  const strokeDashoffset = animatedProgress.interpolate({
    inputRange: [0, 100],
    outputRange: [circumference, 0],
  });
  
  return (
    <View style={styles.container}>
      <Svg width={size} height={size}>
        {/* Background circle */}
        <Circle
          cx={size / 2}
          cy={size / 2}
          r={radius}
          stroke="#e0e0e0"
          strokeWidth={strokeWidth}
          fill="none"
        />
        
        {/* Animated progress circle */}
        <AnimatedCircle
          cx={size / 2}
          cy={size / 2}
          r={radius}
          stroke={color}
          strokeWidth={strokeWidth}
          fill="none"
          strokeLinecap="round"
          strokeDasharray={circumference}
          strokeDashoffset={strokeDashoffset}
          transform={`rotate(-90 ${size / 2} ${size / 2})`}
        />
      </Svg>
      
      {/* Center text */}
      <View style={[styles.textContainer, { width: size, height: size }]}>
        <Text style={styles.progressText}>{displayProgress}%</Text>
      </View>
    </View>
  );
}

// Usage example
function ProgressDemo() {
  const [progress, setProgress] = useState(0);
  
  useEffect(() => {
    // Simulate progress
    const interval = setInterval(() => {
      setProgress(p => {
        if (p >= 100) {
          clearInterval(interval);
          return 100;
        }
        return p + 10;
      });
    }, 500);
    
    return () => clearInterval(interval);
  }, []);
  
  return (
    <View style={styles.demo}>
      <ProgressRing progress={progress} size={150} />
      <Text style={styles.label}>Loading...</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    position: 'relative',
  },
  textContainer: {
    position: 'absolute',
    justifyContent: 'center',
    alignItems: 'center',
  },
  progressText: {
    fontSize: 28,
    fontWeight: 'bold',
    color: '#333',
  },
  demo: {
    alignItems: 'center',
    padding: 40,
  },
  label: {
    marginTop: 16,
    fontSize: 16,
    color: '#666',
  },
});

Summary

Animation is a powerful tool for creating engaging, intuitive mobile experiences. React Native's Animated API provides everything you need to build smooth, performant animations that run on the native thread.

🎯 Key Takeaways

  • Performance matters: Use useNativeDriver: true whenever possible to keep animations on the UI thread
  • Animated values: Use Animated.Value for single values, Animated.ValueXY for 2D positions
  • Interpolation: Map animated values to different output ranges including strings for rotation and colors
  • Animation types:
    • timing — Fixed duration, great for fades and timed sequences
    • spring — Physics-based, ideal for interactive UI
    • decay — Momentum-based, perfect for flick gestures
  • Composition: Use parallel, sequence, and stagger to combine animations
  • Easing: Choose appropriate easing functions for natural-feeling motion

Animation Decision Flowchart

flowchart TD
    A[New Animation Needed] --> B{Property to animate?}
    
    B -->|transform, opacity| C[useNativeDriver: true ✅]
    B -->|width, height, color| D[useNativeDriver: false ⚠️]
    
    C --> E{Type of animation?}
    D --> F[Consider Reanimated]
    
    E -->|Fixed duration| G[Animated.timing]
    E -->|Interactive/responsive| H[Animated.spring]
    E -->|Momentum-based| I[Animated.decay]
    
    G --> J[Choose easing function]
    H --> K[Tune tension/friction]
    I --> L[Set deceleration]
    
    style C fill:#e8f5e9
    style D fill:#fff3e0
    style F fill:#e3f2fd

In the next lesson, we'll explore React Native Reanimated—a more powerful animation library that overcomes many limitations of the built-in Animated API and enables even more complex, performant animations.