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: falseand 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
inputRangevalues must be monotonically increasing- Use
extrapolate: 'clamp'to prevent values outside the range - String outputs (degrees, colors, percentages) require
useNativeDriver: falseexcept 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
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: truewhenever possible to keep animations on the UI thread - Animated values: Use
Animated.Valuefor single values,Animated.ValueXYfor 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 sequencesspring— Physics-based, ideal for interactive UIdecay— Momentum-based, perfect for flick gestures
- Composition: Use
parallel,sequence, andstaggerto 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.