Advanced Animations
React Native animations — Reanimated v3, Gesture Handler, layout animations, Skia, shared transitions
Advanced Animations
Smooth animation on mobile is a 16.67 ms budget per frame. Miss it and users feel it instantly. Most animation approaches in React Native fail because they run on the wrong thread. This guide covers the architecture that makes 60 fps possible and the tools that implement it.
Animation Architecture
JS Thread vs UI Thread
React Native has two execution contexts that matter for animation:
┌─────────────────┐ bridge / JSI ┌──────────────────┐
│ JS Thread │ ←────────────────→ │ UI Thread │
│ (React logic) │ │ (native views) │
└─────────────────┘ └──────────────────┘
setState() Layout + Paint
fetch() Touch delivery
Business logic 60 fps targetWhen an animation value changes on the JS thread and must cross the bridge to update a native view property, the round trip costs serialization time. If the JS thread is also processing a network response or running a re-render, the animation frame is dropped. This is the fundamental problem with most animation approaches.
The Animated API Limitations
The built-in Animated API ships with React Native and offers useNativeDriver to offload animations to the UI thread. But the restriction is severe: useNativeDriver only works with transform and opacity. Any other property (width, height, backgroundColor, borderRadius) falls back to JS-driven frames.
// This works on the UI thread
Animated.timing(opacity, {
toValue: 0,
duration: 300,
useNativeDriver: true, // OK: opacity is supported
}).start();
// This crashes with useNativeDriver: true
Animated.timing(height, {
toValue: 0,
duration: 300,
useNativeDriver: true, // ERROR: height is not supported
}).start();The Animated API also cannot respond to gestures frame-by-frame without crossing the bridge. Animated.event with useNativeDriver helps for scroll-linked animations, but complex gesture-driven interactions still stutter.
Reanimated v3 Worklet Model
Reanimated solves this by running JavaScript functions directly on the UI thread. These functions are called worklets -- normal JavaScript functions marked with the 'worklet' directive that get compiled and shipped to the UI thread runtime via a Babel plugin.
import { useAnimatedStyle, useSharedValue } from 'react-native-reanimated';
const offset = useSharedValue(0);
// This function runs on the UI thread, not the JS thread
const animatedStyle = useAnimatedStyle(() => {
'worklet';
return {
transform: [{ translateX: offset.value }],
backgroundColor: offset.value > 100 ? 'red' : 'blue', // any property works
};
});No bridge crossing. No serialization. The style update happens in the same frame as the gesture or timer tick.
Comparison Table
| Feature | Animated API | Reanimated v2 | Reanimated v3 |
|---|---|---|---|
| UI thread execution | Only transform/opacity | Yes (worklets) | Yes (worklets) |
| Gesture integration | Limited (Animated.event) | react-native-gesture-handler | Same, improved API |
| Animatable properties | transform, opacity (native) | All style properties | All style properties |
| Layout animations | No | Yes | Yes, improved |
| Shared element transitions | No | No | Yes (experimental) |
| API style | Imperative | Hook-based | Hook-based |
| Bundle size impact | 0 (built-in) | ~150 KB | ~150 KB |
| Babel plugin required | No | Yes | Yes |
| Web support | Partial | Partial | Yes |
| New Architecture (Fabric) | Partial | Yes | Yes |
Reanimated v3 is not a rewrite of v2. It is the same library with additional APIs (shared transitions, reduced API for worklet marking). The v2 API is fully supported in v3. The 'worklet' directive is now automatically inferred for useAnimatedStyle callbacks.
Reanimated Core Concepts
Shared Values
useSharedValue creates a reactive value that lives on both threads. Reading .value on the UI thread is synchronous. Reading it on the JS thread reflects the last known value (no bridge call needed because it is backed by a C++ data structure via JSI).
import { useSharedValue } from 'react-native-reanimated';
function MyComponent() {
const translateX = useSharedValue(0);
const opacity = useSharedValue(1);
const scale = useSharedValue(1);
// Updating from JS thread — the value propagates to UI thread automatically
const handlePress = () => {
translateX.value = 100;
};
return <Animated.View style={useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }, { scale: scale.value }],
opacity: opacity.value,
}))} />;
}useAnimatedStyle
Derives a style object from shared values. The callback runs on the UI thread whenever any referenced shared value changes.
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: offset.value },
{ scale: interpolate(offset.value, [0, 100], [1, 1.5], Extrapolation.CLAMP) },
],
opacity: interpolate(offset.value, [0, 50, 100], [1, 0.5, 0]),
borderRadius: offset.value / 10,
}));useDerivedValue
Computes a value from other shared values. Runs on the UI thread. Useful for intermediate calculations that multiple animated styles depend on.
import { useDerivedValue } from 'react-native-reanimated';
const progress = useSharedValue(0);
const clampedProgress = useDerivedValue(() => {
return Math.min(Math.max(progress.value, 0), 1);
});
const backgroundColor = useDerivedValue(() => {
return interpolateColor(
clampedProgress.value,
[0, 0.5, 1],
['#ff0000', '#ffff00', '#00ff00'],
);
});Animation Drivers
Reanimated provides three fundamental animation drivers:
import {
withTiming,
withSpring,
withDecay,
Easing,
} from 'react-native-reanimated';
// Timing: fixed duration, configurable easing
offset.value = withTiming(100, {
duration: 300,
easing: Easing.bezier(0.25, 0.1, 0.25, 1),
});
// Spring: physics-based, no fixed duration
offset.value = withSpring(100, {
damping: 15, // lower = more bouncy
stiffness: 150, // higher = snappier
mass: 1, // higher = slower
overshootClamping: false,
restDisplacementThreshold: 0.01,
restSpeedThreshold: 2,
});
// Decay: velocity-based, slows to stop (fling gestures)
offset.value = withDecay({
velocity: velocityFromGesture,
deceleration: 0.998, // 0.998 is the default
clamp: [0, MAX_SCROLL],
});Animation Composition
Chain, delay, and repeat animations with composition functions:
import {
withSequence,
withDelay,
withRepeat,
} from 'react-native-reanimated';
// Shake animation: sequence of left-right movements
offset.value = withSequence(
withTiming(-10, { duration: 50 }),
withRepeat(withTiming(10, { duration: 100 }), 3, true),
withTiming(0, { duration: 50 }),
);
// Staggered entrance: delay each item
items.forEach((_, index) => {
opacity[index].value = withDelay(
index * 100,
withTiming(1, { duration: 400 }),
);
});
// Infinite pulse
scale.value = withRepeat(
withSequence(
withTiming(1.2, { duration: 600 }),
withTiming(1, { duration: 600 }),
),
-1, // -1 = infinite
true,
);Easing Functions
import { Easing } from 'react-native-reanimated';
// Built-in curves
Easing.linear;
Easing.ease; // equivalent to CSS ease
Easing.in(Easing.quad);
Easing.out(Easing.cubic);
Easing.inOut(Easing.exp);
// Custom cubic bezier (matches CSS transition-timing-function)
Easing.bezier(0.42, 0, 0.58, 1);
// Step function (frame-by-frame sprite animation)
Easing.steps(5, true);Spring Animation with Shared Values (Complete Example)
import React from 'react';
import { Pressable, StyleSheet, Text } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
interpolate,
interpolateColor,
} from 'react-native-reanimated';
const SPRING_CONFIG = {
damping: 12,
stiffness: 180,
mass: 0.8,
};
export function AnimatedCard() {
const pressed = useSharedValue(0); // 0 = idle, 1 = pressed
const cardStyle = useAnimatedStyle(() => {
const scale = interpolate(pressed.value, [0, 1], [1, 0.95]);
const shadowRadius = interpolate(pressed.value, [0, 1], [8, 2]);
const bgColor = interpolateColor(
pressed.value,
[0, 1],
['#ffffff', '#f0f0f0'],
);
return {
transform: [{ scale }],
shadowRadius,
backgroundColor: bgColor,
};
});
return (
<Pressable
onPressIn={() => { pressed.value = withSpring(1, SPRING_CONFIG); }}
onPressOut={() => { pressed.value = withSpring(0, SPRING_CONFIG); }}
>
<Animated.View style={[styles.card, cardStyle]}>
<Text style={styles.text}>Press Me</Text>
</Animated.View>
</Pressable>
);
}
const styles = StyleSheet.create({
card: {
padding: 24,
borderRadius: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
elevation: 6,
},
text: { fontSize: 18, fontWeight: '600', textAlign: 'center' },
});Gesture Handler Integration
The Problem
React Native's built-in PanResponder runs gesture callbacks on the JS thread. Every onPanResponderMove crosses the bridge. If the JS thread is busy, gestures stutter.
react-native-gesture-handler runs gesture recognition on the native thread. Combined with Reanimated, gesture events flow directly to worklets on the UI thread -- zero bridge crossings per frame.
Gesture Composition
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
// Individual gestures
const tap = Gesture.Tap().onEnd(() => { /* ... */ });
const pan = Gesture.Pan().onUpdate((e) => { /* ... */ });
const pinch = Gesture.Pinch().onUpdate((e) => { /* ... */ });
const rotation = Gesture.Rotation().onUpdate((e) => { /* ... */ });
const longPress = Gesture.LongPress().minDuration(500).onEnd(() => { /* ... */ });
const fling = Gesture.Fling().direction(Directions.RIGHT).onEnd(() => { /* ... */ });
// Simultaneous: both gestures can be active at the same time
const pinchAndPan = Gesture.Simultaneous(pinch, pan);
// Exclusive: only the first matching gesture wins
const tapOrLongPress = Gesture.Exclusive(longPress, tap);
// Sequential: second gesture only activates after first completes
const doubleTap = Gesture.Tap().numberOfTaps(2);
const singleTap = Gesture.Tap();
const tapGesture = Gesture.Exclusive(doubleTap, singleTap);GestureDetector
GestureDetector is the modern API (replaces TapGestureHandler, PanGestureHandler, etc.). It wraps a single animated view and accepts a composed gesture.
<GestureDetector gesture={pinchAndPan}>
<Animated.View style={[styles.box, animatedStyle]} />
</GestureDetector>GestureDetector must wrap an Animated.View from Reanimated (not from react-native). Wrapping a plain View will not receive animated style updates.
Pan Gesture Controlling a Shared Value
import React from 'react';
import { StyleSheet, View } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withDecay,
} from 'react-native-reanimated';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
export function DraggableBox() {
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
const context = useSharedValue({ x: 0, y: 0 });
const panGesture = Gesture.Pan()
.onStart(() => {
context.value = { x: translateX.value, y: translateY.value };
})
.onUpdate((event) => {
translateX.value = context.value.x + event.translationX;
translateY.value = context.value.y + event.translationY;
})
.onEnd((event) => {
translateX.value = withDecay({ velocity: event.velocityX });
translateY.value = withDecay({ velocity: event.velocityY });
});
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: translateX.value },
{ translateY: translateY.value },
],
}));
return (
<View style={styles.container}>
<GestureDetector gesture={panGesture}>
<Animated.View style={[styles.box, animatedStyle]} />
</GestureDetector>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', alignItems: 'center' },
box: { width: 100, height: 100, backgroundColor: '#3b82f6', borderRadius: 12 },
});Common Animation Patterns
Drag and Drop with Snap-Back
A draggable card that snaps back to its origin when released, using spring physics.
import React from 'react';
import { StyleSheet, View, Text } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
runOnJS,
} from 'react-native-reanimated';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
const SNAP_POINTS = [0, 120, 240];
function snapTo(value: number, points: number[]): number {
'worklet';
return points.reduce((prev, curr) =>
Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev
);
}
export function DraggableCard() {
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
const startX = useSharedValue(0);
const startY = useSharedValue(0);
const scale = useSharedValue(1);
const gesture = Gesture.Pan()
.onStart(() => {
startX.value = translateX.value;
startY.value = translateY.value;
scale.value = withSpring(1.05);
})
.onUpdate((e) => {
translateX.value = startX.value + e.translationX;
translateY.value = startY.value + e.translationY;
})
.onEnd(() => {
// Snap to nearest grid point on X axis, snap back to 0 on Y
translateX.value = withSpring(snapTo(translateX.value, SNAP_POINTS), {
damping: 15,
stiffness: 150,
});
translateY.value = withSpring(0, { damping: 15, stiffness: 150 });
scale.value = withSpring(1);
});
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: translateX.value },
{ translateY: translateY.value },
{ scale: scale.value },
],
}));
return (
<View style={styles.snapContainer}>
<GestureDetector gesture={gesture}>
<Animated.View style={[styles.card, animatedStyle]}>
<Text style={styles.cardText}>Drag and release</Text>
</Animated.View>
</GestureDetector>
</View>
);
}
const styles = StyleSheet.create({
snapContainer: { flex: 1, justifyContent: 'center', alignItems: 'center' },
card: {
width: 200,
height: 120,
backgroundColor: '#6366f1',
borderRadius: 16,
justifyContent: 'center',
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 8,
},
cardText: { color: '#fff', fontSize: 16, fontWeight: '600' },
});Swipe to Delete
A list item that reveals a delete action on swipe, collapses its height when deleted, and calls back to the parent.
import React, { useCallback } from 'react';
import { StyleSheet, Text, View, Dimensions } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
withSpring,
interpolate,
interpolateColor,
runOnJS,
Extrapolation,
} from 'react-native-reanimated';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
const SCREEN_WIDTH = Dimensions.get('window').width;
const SWIPE_THRESHOLD = -SCREEN_WIDTH * 0.3;
interface SwipeableRowProps {
label: string;
onDelete: () => void;
}
export function SwipeableRow({ label, onDelete }: SwipeableRowProps) {
const translateX = useSharedValue(0);
const rowHeight = useSharedValue(72);
const opacity = useSharedValue(1);
const handleDelete = useCallback(() => {
onDelete();
}, [onDelete]);
const panGesture = Gesture.Pan()
.activeOffsetX([-10, 10])
.onUpdate((e) => {
translateX.value = Math.min(0, e.translationX); // only allow left swipe
})
.onEnd((e) => {
if (translateX.value < SWIPE_THRESHOLD) {
// Swipe past threshold — animate out and collapse
translateX.value = withTiming(-SCREEN_WIDTH, { duration: 200 });
rowHeight.value = withTiming(0, { duration: 300 });
opacity.value = withTiming(0, { duration: 300 }, () => {
runOnJS(handleDelete)();
});
} else {
// Snap back
translateX.value = withSpring(0);
}
});
const rowStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }],
}));
const containerStyle = useAnimatedStyle(() => ({
height: rowHeight.value,
opacity: opacity.value,
overflow: 'hidden' as const,
}));
const deleteStyle = useAnimatedStyle(() => {
const deleteOpacity = interpolate(
translateX.value,
[SWIPE_THRESHOLD, 0],
[1, 0],
Extrapolation.CLAMP,
);
return { opacity: deleteOpacity };
});
return (
<Animated.View style={containerStyle}>
<View style={styles.deleteBackground}>
<Animated.Text style={[styles.deleteText, deleteStyle]}>
Delete
</Animated.Text>
</View>
<GestureDetector gesture={panGesture}>
<Animated.View style={[styles.row, rowStyle]}>
<Text style={styles.rowText}>{label}</Text>
</Animated.View>
</GestureDetector>
</Animated.View>
);
}
const styles = StyleSheet.create({
deleteBackground: {
...StyleSheet.absoluteFillObject,
backgroundColor: '#ef4444',
justifyContent: 'center',
alignItems: 'flex-end',
paddingRight: 24,
},
deleteText: { color: '#fff', fontWeight: '700', fontSize: 16 },
row: {
height: 72,
backgroundColor: '#fff',
justifyContent: 'center',
paddingHorizontal: 20,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#e5e7eb',
},
rowText: { fontSize: 16 },
});Pinch to Zoom (Image Viewer)
Combines pinch and pan gestures simultaneously for a production-quality image viewer with bounds clamping.
import React from 'react';
import { StyleSheet, View, Dimensions, Image } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
clamp,
} from 'react-native-reanimated';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
const MIN_SCALE = 1;
const MAX_SCALE = 5;
export function ImageViewer({ uri }: { uri: string }) {
const scale = useSharedValue(1);
const savedScale = useSharedValue(1);
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
const savedTranslateX = useSharedValue(0);
const savedTranslateY = useSharedValue(0);
const pinchGesture = Gesture.Pinch()
.onStart(() => {
savedScale.value = scale.value;
})
.onUpdate((e) => {
scale.value = clamp(savedScale.value * e.scale, MIN_SCALE, MAX_SCALE);
})
.onEnd(() => {
if (scale.value < 1) {
scale.value = withTiming(1);
translateX.value = withTiming(0);
translateY.value = withTiming(0);
}
});
const panGesture = Gesture.Pan()
.minPointers(2)
.onStart(() => {
savedTranslateX.value = translateX.value;
savedTranslateY.value = translateY.value;
})
.onUpdate((e) => {
const maxX = (SCREEN_WIDTH * (scale.value - 1)) / 2;
const maxY = (SCREEN_HEIGHT * (scale.value - 1)) / 2;
translateX.value = clamp(
savedTranslateX.value + e.translationX,
-maxX,
maxX,
);
translateY.value = clamp(
savedTranslateY.value + e.translationY,
-maxY,
maxY,
);
});
const doubleTap = Gesture.Tap()
.numberOfTaps(2)
.onEnd((e) => {
if (scale.value > 1) {
scale.value = withTiming(1);
translateX.value = withTiming(0);
translateY.value = withTiming(0);
} else {
scale.value = withTiming(2.5);
translateX.value = withTiming(
(SCREEN_WIDTH / 2 - e.x) * 1.5,
);
translateY.value = withTiming(
(SCREEN_HEIGHT / 2 - e.y) * 1.5,
);
}
});
const composedGesture = Gesture.Simultaneous(
pinchGesture,
panGesture,
doubleTap,
);
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: translateX.value },
{ translateY: translateY.value },
{ scale: scale.value },
],
}));
return (
<View style={styles.viewerContainer}>
<GestureDetector gesture={composedGesture}>
<Animated.Image
source={{ uri }}
style={[styles.image, animatedStyle]}
resizeMode="contain"
/>
</GestureDetector>
</View>
);
}
const styles = StyleSheet.create({
viewerContainer: {
flex: 1,
backgroundColor: '#000',
justifyContent: 'center',
alignItems: 'center',
},
image: { width: SCREEN_WIDTH, height: SCREEN_HEIGHT },
});Bottom Sheet with Multiple Snap Points
A custom bottom sheet driven by pan gestures and velocity-based snap logic.
import React, { useCallback } from 'react';
import { StyleSheet, View, Text, Dimensions } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
interpolate,
useAnimatedScrollHandler,
clamp,
} from 'react-native-reanimated';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
const SNAP_POINTS = [SCREEN_HEIGHT * 0.08, SCREEN_HEIGHT * 0.5, SCREEN_HEIGHT * 0.92];
// SNAP_POINTS: collapsed (8%), half (50%), expanded (92%)
function snapToNearest(y: number, velocity: number, points: number[]): number {
'worklet';
// If velocity is high enough, snap in the direction of the swipe
if (Math.abs(velocity) > 500) {
const direction = velocity > 0 ? 1 : -1; // positive velocity = downward
const currentIndex = points.reduce((closest, point, index) =>
Math.abs(point - y) < Math.abs(points[closest] - y) ? index : closest, 0);
const nextIndex = clamp(currentIndex + direction, 0, points.length - 1);
return points[nextIndex];
}
// Otherwise snap to nearest
return points.reduce((prev, curr) =>
Math.abs(curr - y) < Math.abs(prev - y) ? curr : prev
);
}
export function BottomSheet({ children }: { children: React.ReactNode }) {
// translateY represents how far down from the top. Higher value = more collapsed.
const translateY = useSharedValue(SCREEN_HEIGHT - SNAP_POINTS[0]);
const context = useSharedValue(0);
const panGesture = Gesture.Pan()
.onStart(() => {
context.value = translateY.value;
})
.onUpdate((e) => {
translateY.value = clamp(
context.value + e.translationY,
SCREEN_HEIGHT - SNAP_POINTS[2],
SCREEN_HEIGHT - SNAP_POINTS[0],
);
})
.onEnd((e) => {
const currentHeight = SCREEN_HEIGHT - translateY.value;
const targetHeight = snapToNearest(currentHeight, -e.velocityY, SNAP_POINTS);
translateY.value = withSpring(SCREEN_HEIGHT - targetHeight, {
damping: 20,
stiffness: 200,
mass: 0.5,
});
});
const sheetStyle = useAnimatedStyle(() => ({
transform: [{ translateY: translateY.value }],
}));
const backdropStyle = useAnimatedStyle(() => ({
opacity: interpolate(
translateY.value,
[SCREEN_HEIGHT - SNAP_POINTS[2], SCREEN_HEIGHT - SNAP_POINTS[0]],
[0.5, 0],
),
}));
return (
<>
<Animated.View
style={[styles.backdrop, backdropStyle]}
pointerEvents="none"
/>
<GestureDetector gesture={panGesture}>
<Animated.View style={[styles.sheet, sheetStyle]}>
<View style={styles.handle} />
{children}
</Animated.View>
</GestureDetector>
</>
);
}
const styles = StyleSheet.create({
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: '#000',
},
sheet: {
position: 'absolute',
left: 0,
right: 0,
height: SCREEN_HEIGHT,
backgroundColor: '#fff',
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
shadowColor: '#000',
shadowOffset: { width: 0, height: -4 },
shadowOpacity: 0.1,
shadowRadius: 12,
elevation: 16,
},
handle: {
width: 40,
height: 4,
backgroundColor: '#d1d5db',
borderRadius: 2,
alignSelf: 'center',
marginTop: 12,
marginBottom: 8,
},
});Shared Element Transitions
Reanimated v3 provides SharedTransitionTag for animating elements between screens. Assign the same tag to the source and destination components, and Reanimated interpolates position, size, and style between them.
import Animated from 'react-native-reanimated';
import { SharedTransition } from 'react-native-reanimated';
// Custom transition configuration
const customTransition = SharedTransition.custom((values) => {
'worklet';
return {
originX: withSpring(values.targetOriginX),
originY: withSpring(values.targetOriginY),
width: withSpring(values.targetWidth),
height: withSpring(values.targetHeight),
};
});
// Source screen (list item)
function ListScreen({ navigation }) {
return (
<Pressable onPress={() => navigation.navigate('Detail', { id: item.id })}>
<Animated.Image
source={{ uri: item.image }}
style={styles.thumbnail}
sharedTransitionTag={`image-${item.id}`}
sharedTransitionStyle={customTransition}
/>
<Animated.Text
style={styles.title}
sharedTransitionTag={`title-${item.id}`}
>
{item.title}
</Animated.Text>
</Pressable>
);
}
// Destination screen (detail)
function DetailScreen({ route }) {
const { id } = route.params;
return (
<View>
<Animated.Image
source={{ uri: item.image }}
style={styles.hero}
sharedTransitionTag={`image-${id}`}
sharedTransitionStyle={customTransition}
/>
<Animated.Text
style={styles.detailTitle}
sharedTransitionTag={`title-${id}`}
>
{item.title}
</Animated.Text>
</View>
);
}Shared transitions require react-native-screens with native stack navigator. They do not work with JS-based stack navigators. As of Reanimated v3.6, the API is stable on iOS and Android but should be tested thoroughly in your specific navigation structure.
Layout Animations
Layout animations automatically animate components when they enter, exit, or change layout position. They require no manual shared values.
Entering and Exiting
import Animated, {
FadeIn,
FadeOut,
SlideInLeft,
SlideOutRight,
BounceIn,
ZoomIn,
FlipInXUp,
LightSpeedInLeft,
} from 'react-native-reanimated';
// Fade in over 500ms with a 200ms delay
<Animated.View entering={FadeIn.duration(500).delay(200)}>
<Text>Hello</Text>
</Animated.View>
// Slide in from left with spring physics
<Animated.View entering={SlideInLeft.springify().damping(15)}>
<Text>Slide</Text>
</Animated.View>
// Exit to the right
<Animated.View exiting={SlideOutRight.duration(300)}>
<Text>Goodbye</Text>
</Animated.View>Layout Transitions
When a component's position or size changes (sibling added/removed, flex changes), the layout prop animates the change smoothly.
import { LinearTransition } from 'react-native-reanimated';
<Animated.View layout={LinearTransition.springify()}>
<Text>{item.title}</Text>
</Animated.View>Custom Entering/Exiting Animations
import { EntryAnimationsValues, ExitAnimationsValues } from 'react-native-reanimated';
const CustomEntering = (values: EntryAnimationsValues) => {
'worklet';
const animations = {
originX: withSpring(values.targetOriginX),
originY: withSpring(values.targetOriginY),
opacity: withTiming(1, { duration: 300 }),
transform: [
{ rotate: withSpring('0deg') },
{ scale: withSpring(1) },
],
};
const initialValues = {
originX: values.targetOriginX - 100,
originY: values.targetOriginY,
opacity: 0,
transform: [
{ rotate: '-45deg' },
{ scale: 0.5 },
],
};
return { initialValues, animations };
};
<Animated.View entering={CustomEntering}>
<Text>Custom entrance</Text>
</Animated.View>Animated List with Entering/Exiting Items
import React, { useState, useCallback } from 'react';
import { StyleSheet, View, Text, Pressable, FlatList } from 'react-native';
import Animated, {
FadeInRight,
FadeOutLeft,
LinearTransition,
} from 'react-native-reanimated';
interface Item {
id: string;
label: string;
}
let counter = 0;
export function AnimatedList() {
const [items, setItems] = useState<Item[]>([]);
const addItem = useCallback(() => {
const id = String(++counter);
setItems((prev) => [{ id, label: `Item ${id}` }, ...prev]);
}, []);
const removeItem = useCallback((id: string) => {
setItems((prev) => prev.filter((item) => item.id !== id));
}, []);
const renderItem = useCallback(({ item }: { item: Item }) => (
<Animated.View
entering={FadeInRight.duration(300)}
exiting={FadeOutLeft.duration(300)}
layout={LinearTransition.springify()}
style={styles.listItem}
>
<Text style={styles.listItemText}>{item.label}</Text>
<Pressable onPress={() => removeItem(item.id)}>
<Text style={styles.removeBtn}>Remove</Text>
</Pressable>
</Animated.View>
), [removeItem]);
return (
<View style={styles.listContainer}>
<Pressable style={styles.addBtn} onPress={addItem}>
<Text style={styles.addBtnText}>Add Item</Text>
</Pressable>
<FlatList
data={items}
keyExtractor={(item) => item.id}
renderItem={renderItem}
itemLayoutAnimation={LinearTransition}
/>
</View>
);
}
const styles = StyleSheet.create({
listContainer: { flex: 1, padding: 16 },
addBtn: {
backgroundColor: '#3b82f6',
padding: 12,
borderRadius: 8,
marginBottom: 16,
},
addBtnText: { color: '#fff', textAlign: 'center', fontWeight: '600' },
listItem: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 16,
backgroundColor: '#f9fafb',
borderRadius: 8,
marginBottom: 8,
},
listItemText: { fontSize: 16 },
removeBtn: { color: '#ef4444', fontWeight: '600' },
});React Native Skia
When to Use Skia
@shopify/react-native-skia provides a GPU-accelerated 2D canvas. Reanimated animates React Native views (translate, scale, opacity). Skia draws arbitrary shapes, paths, gradients, shaders, and image filters.
| Use Case | Tool |
|---|---|
| Moving/scaling existing components | Reanimated |
| Custom chart rendering | Skia |
| Particle effects | Skia |
| Blur / image filters | Skia |
| Path morphing | Skia |
| SVG-like drawing | Skia |
| Component list animations | Reanimated Layout Animations |
| Gesture-driven transforms | Reanimated + Gesture Handler |
Core Primitives
import {
Canvas,
Circle,
Path,
LinearGradient,
Blur,
vec,
Skia,
} from '@shopify/react-native-skia';
function SkiaBasics() {
return (
<Canvas style={{ width: 300, height: 300 }}>
{/* Circle with gradient fill */}
<Circle cx={150} cy={150} r={100}>
<LinearGradient
start={vec(50, 50)}
end={vec(250, 250)}
colors={['#6366f1', '#ec4899']}
/>
</Circle>
{/* Path with blur */}
<Path
path="M 50 150 Q 150 50 250 150"
style="stroke"
strokeWidth={4}
color="#10b981"
>
<Blur blur={2} />
</Path>
</Canvas>
);
}Integrating Skia with Reanimated
Skia values can be driven by Reanimated shared values using useDerivedValue:
import { Canvas, Circle, vec } from '@shopify/react-native-skia';
import {
useSharedValue,
useDerivedValue,
withRepeat,
withTiming,
} from 'react-native-reanimated';
import { useEffect } from 'react';
export function PulsingCircle() {
const progress = useSharedValue(0);
useEffect(() => {
progress.value = withRepeat(
withTiming(1, { duration: 2000 }),
-1,
true,
);
}, []);
const radius = useDerivedValue(() => 40 + progress.value * 60);
const opacity = useDerivedValue(() => 1 - progress.value * 0.6);
return (
<Canvas style={{ width: 200, height: 200 }}>
<Circle cx={100} cy={100} r={radius} color="#6366f1" opacity={opacity} />
</Canvas>
);
}Custom Animated Graph with Skia
A line graph that animates in from left to right with smooth path interpolation.
import React, { useEffect } from 'react';
import { StyleSheet, View } from 'react-native';
import {
Canvas,
Path as SkiaPath,
Skia,
LinearGradient,
vec,
Group,
Circle,
} from '@shopify/react-native-skia';
import {
useSharedValue,
useDerivedValue,
withTiming,
Easing,
} from 'react-native-reanimated';
const GRAPH_WIDTH = 320;
const GRAPH_HEIGHT = 200;
const PADDING = 20;
const DATA = [20, 45, 28, 80, 55, 90, 60, 75, 40, 85, 65, 95];
function buildPath(data: number[], width: number, height: number, progress: number): string {
'worklet';
const maxVal = Math.max(...data);
const stepX = (width - PADDING * 2) / (data.length - 1);
const visiblePoints = Math.floor(progress * data.length);
if (visiblePoints < 2) return '';
let path = '';
for (let i = 0; i < visiblePoints; i++) {
const x = PADDING + i * stepX;
const y = height - PADDING - (data[i] / maxVal) * (height - PADDING * 2);
if (i === 0) {
path += `M ${x} ${y}`;
} else {
// Smooth curve using quadratic bezier
const prevX = PADDING + (i - 1) * stepX;
const prevY = height - PADDING - (data[i - 1] / maxVal) * (height - PADDING * 2);
const cpX = (prevX + x) / 2;
path += ` Q ${cpX} ${prevY} ${x} ${y}`;
}
}
return path;
}
export function AnimatedGraph() {
const progress = useSharedValue(0);
useEffect(() => {
progress.value = withTiming(1, {
duration: 1500,
easing: Easing.out(Easing.cubic),
});
}, []);
const animatedPath = useDerivedValue(() => {
return buildPath(DATA, GRAPH_WIDTH, GRAPH_HEIGHT, progress.value);
});
const lastPointX = useDerivedValue(() => {
const visiblePoints = Math.floor(progress.value * DATA.length);
if (visiblePoints < 1) return 0;
const stepX = (GRAPH_WIDTH - PADDING * 2) / (DATA.length - 1);
return PADDING + (visiblePoints - 1) * stepX;
});
const lastPointY = useDerivedValue(() => {
const visiblePoints = Math.floor(progress.value * DATA.length);
if (visiblePoints < 1) return 0;
const maxVal = Math.max(...DATA);
return GRAPH_HEIGHT - PADDING - (DATA[visiblePoints - 1] / maxVal) * (GRAPH_HEIGHT - PADDING * 2);
});
return (
<View style={styles.graphContainer}>
<Canvas style={{ width: GRAPH_WIDTH, height: GRAPH_HEIGHT }}>
<SkiaPath
path={animatedPath}
style="stroke"
strokeWidth={3}
strokeCap="round"
strokeJoin="round"
color="#6366f1"
/>
<Circle cx={lastPointX} cy={lastPointY} r={5} color="#6366f1" />
</Canvas>
</View>
);
}
const styles = StyleSheet.create({
graphContainer: {
alignItems: 'center',
padding: 16,
backgroundColor: '#fff',
borderRadius: 16,
margin: 16,
},
});For production charts, consider react-native-graph (by Margelo) or victory-native, both of which use Skia internally and handle axes, labels, and gestures. Building from scratch with Skia is appropriate when you need full visual control or non-standard chart types.
Performance Considerations for Skia
- Skia runs on the GPU. Drawing many small elements (hundreds of circles, complex paths) increases GPU load but frees the CPU.
- Avoid re-creating
Skia.Pathobjects every frame. Build the path once and animate transform or clip properties. Canvasallocates an offscreen surface. Multiple overlapping canvases multiply memory usage.- Use
Picturefor static complex drawings -- it records drawing commands once and replays them. - Profile with Xcode GPU debugger (iOS) or Android GPU Inspector for overdraw analysis.
Performance Optimization
Verify Worklets Run on the UI Thread
If a function called inside useAnimatedStyle is not a worklet, it silently falls back to the JS thread, killing performance. Add the 'worklet' directive explicitly when the automatic inference is uncertain.
// This helper must be marked as a worklet to run on the UI thread
function clampValue(val: number, min: number, max: number): number {
'worklet';
return Math.min(Math.max(val, min), max);
}useAnimatedReaction for Side Effects
When you need to trigger a side effect (haptic feedback, sound, JS-thread callback) based on a shared value change:
import { useAnimatedReaction, runOnJS } from 'react-native-reanimated';
import * as Haptics from 'expo-haptics';
function triggerHaptic() {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
}
useAnimatedReaction(
() => Math.round(progress.value * 10), // derived value (granularity)
(current, previous) => {
if (current !== previous) {
runOnJS(triggerHaptic)();
}
},
);cancelAnimation for Cleanup
Always cancel running animations when a component unmounts or when starting a new animation on the same shared value.
import { cancelAnimation } from 'react-native-reanimated';
useEffect(() => {
progress.value = withRepeat(withTiming(1, { duration: 1000 }), -1, true);
return () => {
cancelAnimation(progress);
};
}, []);Measuring Animation Performance
| Tool | What to Check |
|---|---|
| In-app Performance Monitor | JS thread FPS and UI thread FPS separately |
| Flipper Performance Plugin | Frame timing breakdown |
console.log in worklet | Should print [reanimated] prefix (confirms UI thread) |
| Xcode Instruments (Core Animation) | Offscreen rendering, blending, frame drops |
| Android GPU Profiler | Overdraw, frame rendering time |
Common Performance Pitfalls
// BAD: too many shared values per component (10+ becomes expensive)
const sv1 = useSharedValue(0);
const sv2 = useSharedValue(0);
// ... sv3 through sv20
// BETTER: group related values into a single shared value object
const state = useSharedValue({ x: 0, y: 0, scale: 1, rotation: 0 });
// BAD: heavy computation inside a worklet
const style = useAnimatedStyle(() => {
// Sorting a 1000-element array 60 times per second
const sorted = bigArray.sort((a, b) => a - b); // DO NOT do this
return { opacity: sorted[0] };
});
// BAD: reading .value on every render (triggers re-render)
function Component() {
const sv = useSharedValue(0);
console.log(sv.value); // reading on JS thread re-renders
return <Animated.View />;
}Testing Animations
Jest Setup for Reanimated
Reanimated requires a mock to run in Jest because worklets depend on native modules.
// jest.setup.js
require('react-native-reanimated').setUpTests();
// jest.config.js
module.exports = {
preset: 'react-native',
setupFilesAfterSetup: ['./jest.setup.js'],
transformIgnorePatterns: [
'node_modules/(?!(react-native|react-native-reanimated|react-native-gesture-handler)/)',
],
};Testing Animated Component States
With the mock, shared values update synchronously, so you can test final states:
import { render, fireEvent } from '@testing-library/react-native';
import { AnimatedCard } from './AnimatedCard';
import { getAnimatedStyle } from 'react-native-reanimated';
test('card scales down on press', () => {
const { getByTestId } = render(<AnimatedCard />);
const card = getByTestId('animated-card');
fireEvent(card, 'pressIn');
const style = getAnimatedStyle(card);
expect(style.transform).toContainEqual(
expect.objectContaining({ scale: expect.closeTo(0.95, 1) }),
);
});Snapshot Testing Limitations
Snapshots capture the React tree structure but not animated style values (those live on the native side). Snapshots are useful for verifying component structure but should not be relied on for animation correctness.
Visual Regression Testing
For true animation verification, use screenshot-based testing:
- Detox: Run E2E tests that trigger gestures and capture screenshots at key frames.
- Appium + Applitools: Visual regression with AI-based diff detection.
- Maestro: Simpler E2E framework with screenshot assertion support.
# maestro/test-animation.yaml
appId: com.myapp
---
- launchApp
- tapOn: "Animated Card"
- assertVisible: "Press Me"
- longPressOn: "Press Me"
- takeScreenshot: card-pressed-stateAnti-Patterns
Using Animated API for Complex Gestures
The built-in Animated API with useNativeDriver is limited to opacity and transforms. For anything gesture-driven or involving multiple properties, use Reanimated.
// WRONG: Animated API cannot handle this natively
Animated.timing(backgroundColor, {
toValue: 1,
useNativeDriver: true, // crashes at runtime
}).start();
// RIGHT: Reanimated handles any property
bgProgress.value = withTiming(1);
const style = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(bgProgress.value, [0, 1], ['#fff', '#000']),
}));Creating Shared Values Inside Render
// WRONG: shared value is recreated every render, losing animation state
function BadComponent() {
const opacity = useSharedValue(1); // fine as a hook...
return items.map((item) => {
const scale = useSharedValue(1); // WRONG: hooks in a loop
return <Animated.View key={item.id} />;
});
}
// RIGHT: use a ref map or create shared values at the list-item level
function GoodItem({ item }: { item: Item }) {
const scale = useSharedValue(1); // one per component instance
return <Animated.View />;
}Heavy Computation in Worklets
Worklets run on the UI thread. Blocking the UI thread with expensive computation drops frames just as badly as blocking the JS thread.
// WRONG: expensive work in a worklet
const style = useAnimatedStyle(() => {
// Parsing JSON, complex math, or large array operations
const result = expensiveCalculation(data.value);
return { opacity: result };
});
// RIGHT: do heavy work on JS thread, send result via shared value
useEffect(() => {
const result = expensiveCalculation(data);
computedResult.value = result;
}, [data]);
const style = useAnimatedStyle(() => ({
opacity: computedResult.value,
}));Animating Layout Properties Without LayoutAnimation
Changing width, height, margin, or padding triggers a full layout recalculation. Without Reanimated's layout animation system, this causes visible jank.
// WRONG: raw state change causes layout jump
const [expanded, setExpanded] = useState(false);
<View style={{ height: expanded ? 300 : 0 }} /> // snaps, no animation
// RIGHT: use Reanimated's layout animation or animated height
const height = useSharedValue(0);
const style = useAnimatedStyle(() => ({
height: height.value,
overflow: 'hidden',
}));
// Then: height.value = withTiming(expanded ? 300 : 0);Not Cleaning Up Animations on Unmount
Animations that continue after a component unmounts write to deallocated shared values. While Reanimated handles this gracefully in most cases, it wastes CPU cycles and can cause warnings.
// WRONG: no cleanup
useEffect(() => {
offset.value = withRepeat(withTiming(100, { duration: 1000 }), -1, true);
}, []);
// RIGHT: cancel on unmount
useEffect(() => {
offset.value = withRepeat(withTiming(100, { duration: 1000 }), -1, true);
return () => cancelAnimation(offset);
}, []);When using runOnJS inside gesture handlers or useAnimatedReaction, ensure the target function is defined outside the component render body or wrapped in useCallback. Otherwise, the worklet captures a stale closure reference.