react-native-animations
Comprehensive guide to iOS-native animations in React Native using Reanimated v4. Use when implementing animations, transitions, gestures, or motion design. Covers timing standards, spring physics, gesture handlers, and performance optimization for 60 FPS on iOS.
- Category: discovered
- Tags: github, community
- Version: 1.0.0
- Author: walid
- Downloads: 0
- Updated: 2025-11-22
Download the latest version (v1.0.0) from this page.
SKILL.md
---
name: react-native-animations
description: Comprehensive guide to iOS-native animations in React Native using Reanimated v4. Use when implementing animations, transitions, gestures, or motion design. Covers timing standards, spring physics, gesture handlers, and performance optimization for 60 FPS on iOS.
---
# React Native animations that feel like native iOS: The complete Reanimated v4 guide
React Native Reanimated v4 fundamentally changes how you build animations by bringing CSS-like syntax to mobile while maintaining powerful worklet-based APIs that run entirely on the UI thread. **The critical insight: achieving iOS-native feel requires matching Apple's exact timing values (350ms for navigation, 440ms for modals), using spring configurations with damping: 15 and stiffness: 170, and animating only transform and opacity properties.** Reanimated v4 requires the New Architecture (Fabric) and separates worklets into a standalone package, but delivers 2-4x faster execution by eliminating bridge crossing. This guide provides production-ready code for every common pattern in travel apps—from swipeable cards to bottom sheets—with configurations proven to maintain 60 FPS on iPhone 11 and older devices.
## Understanding Reanimated v4's architecture and when to use it
Reanimated v4 marks a paradigm shift by requiring New Architecture exclusively while introducing CSS-compatible animations. The worklet system now lives in a separate `react-native-worklets` package, requiring explicit babel configuration. The architecture runs animations on the UI thread, completely bypassing the JavaScript bridge that causes performance bottlenecks in traditional React Native animations.
**Installation requires two packages:**
```typescript
npm install react-native-reanimated@4 react-native-worklets
```
**Babel configuration is mandatory** and the worklets plugin must be last:
```javascript
// babel.config.js
module.exports = {
presets: ['babel-preset-expo'],
plugins: [
'react-native-worklets/plugin' // MUST BE LAST!
]
};
```
The v4 API maintains backward compatibility with v2/v3 worklet syntax while adding new CSS-like capabilities. You can use `useSharedValue` with `useAnimatedStyle` for full control, or leverage CSS transition properties for familiar web-like syntax. **Key architectural change:** v4 deprecated `runOnUI` in favor of `scheduleOnUI` from the worklets package, though automatic workletization means you rarely need explicit scheduling.
The choice between v3 and v4 depends on your architecture—v4 only works with New Architecture, but offers better optimization because Reanimated knows exactly what's animating through CSS property declarations. For Expo projects on SDK 51+, v4 works seamlessly with development builds but requires the New Architecture flag enabled.
## Core Reanimated v4 API: useSharedValue, useAnimatedStyle, and animation functions
Reanimated's core revolves around three hooks that enable UI-thread animations. **useSharedValue** creates mutable state that persists across renders without triggering re-renders:
```typescript
const offset = useSharedValue(0);
offset.value = 100; // Standard access
offset.set(100); // React Compiler compatible
// For objects, reassign entirely
offset.value = { x: 50, y: 100 }; // ✅ Correct
offset.value.x = 50; // ❌ Won't trigger updates
```
**Critical rule:** Never read or modify shared values during render—this violates React's rules. Modifications must happen in event handlers, worklets, or animation callbacks.
**useAnimatedStyle** connects shared values to component styling, automatically re-executing when dependencies change:
```typescript
const animatedStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
transform: [
{ translateX: offset.value },
{ scale: scale.value }
]
}));
return <Animated.View style={[styles.static, animatedStyle]} />;
```
Only put dynamic styles in `useAnimatedStyle`—static styles belong in StyleSheet for performance. The worklet runs on the UI thread, so you can perform calculations and interpolations directly without bridge crossing.
**Animation functions** provide the motion characteristics. **withTiming** creates time-based animations:
```typescript
offset.value = withTiming(100, {
duration: 350, // iOS navigation standard
easing: Easing.bezier(0.42, 0, 0.58, 1) // iOS easeInEaseOut
}, (finished) => {
console.log(`Animation ${finished ? 'completed' : 'cancelled'}`);
});
```
**withSpring** creates physics-based animations using either mass-stiffness-damping or duration-dampingRatio models:
```typescript
// Physics-based (iOS default spring)
offset.value = withSpring(100, {
damping: 15, // Higher = faster settling
stiffness: 170, // Higher = snappier
mass: 1 // Lower = faster movement
});
// Duration-based (iOS 17+ style)
offset.value = withSpring(100, {
duration: 550, // Perceptual duration
dampingRatio: 1, // 1 = no bounce, <1 = bouncy
mass: 4 // Default for duration-based
});
```
**withDecay** creates momentum-based animations perfect for fling gestures:
```typescript
offset.value = withDecay({
velocity: event.velocityX,
deceleration: 0.998, // Higher = slower deceleration
clamp: [-200, 200] // Boundary limits
});
```
Reanimated supports animating numbers, percentages, degrees, colors (RGB, HSL, hex), and transform matrices. The automatic type handling means you can animate from `"#FF0000"` to `"#00FF00"` directly.
## Converting iOS animation standards to exact Reanimated configurations
Apple's Human Interface Guidelines specify precise timing values that developers should match for native feel. **The gold standard: 350ms for UINavigationController push/pop transitions using cubic-bezier(0.42, 0, 0.58, 1) easing**. Modal presentations typically use 200-300ms with the same curve. These exact values create the psychological expectation of responsiveness iOS users anticipate.
**iOS navigation timing in Reanimated:**
```typescript
// Matches UIView.animate default
withTiming(toValue, {
duration: 350,
easing: Easing.bezier(0.42, 0, 0.58, 1) // iOS easeInEaseOut
});
```
**iOS modal presentation:**
```typescript
// Slide up with backdrop
backdropOpacity.value = withTiming(1, { duration: 300 });
translateY.value = withSpring(0, {
damping: 20,
stiffness: 200,
mass: 1,
overshootClamping: true // No bounce, critically damped
});
```
**SwiftUI spring parameter conversion** requires understanding the relationship between response/dampingFraction and stiffness/damping. SwiftUI's default spring uses `response: 0.55, dampingFraction: 0.825`, which translates to:
```typescript
// SwiftUI .spring() default
withSpring(value, {
damping: 15,
stiffness: 170,
mass: 1
});
```
The mapping between systems:
- **iOS .smooth** (no bounce) → `damping: 20, stiffness: 200, overshootClamping: true`
- **iOS .snappy** (quick response) → `damping: 12, stiffness: 300`
- **iOS .bouncy** (playful) → `damping: 5, stiffness: 150`
- **iOS .interactiveSpring** → `damping: 15, stiffness: 400` (for gesture-driven)
**Cubic bezier conversions** from iOS/UIKit map directly to Reanimated's Easing.bezier:
```typescript
// iOS curve presets
const iOSCurves = {
easeInOut: Easing.bezier(0.42, 0, 0.58, 1),
easeIn: Easing.bezier(0.42, 0, 1, 1),
easeOut: Easing.bezier(0, 0, 0.58, 1),
linear: Easing.linear
};
```
**Critical insight for travel apps:** Most iOS animations don't bounce. Apple uses critically damped springs (dampingRatio = 1.0) throughout the OS for navigation, sheet presentation, and control center. Only use bouncy springs (dampingRatio < 0.8) for attention-getting elements or playful feedback, not primary navigation.
**Platform-specific timing configuration:**
```typescript
import { Platform } from 'react-native';
const springConfig = Platform.select({
ios: {
damping: 15,
stiffness: 170,
mass: 1
},
android: {
damping: 20,
stiffness: 200,
mass: 1
},
default: {
damping: 15,
stiffness: 170,
mass: 1
}
});
offset.value = withSpring(100, springConfig);
```
## Essential gesture handler patterns: Pan, Tap, and composition strategies
React Native Gesture Handler provides native-driven gesture recognition that integrates seamlessly with Reanimated through automatic workletization. **The killer feature: gesture callbacks automatically run on the UI thread when Reanimated is installed, enabling direct shared value manipulation without runOnJS.**
**Pan gesture for draggable elements:**
```typescript
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
const startX = useSharedValue(0);
const startY = useSharedValue(0);
const panGesture = Gesture.Pan()
.onStart(() => {
startX.value = translateX.value;
startY.value = translateY.value;
})
.onChange((event) => {
translateX.value = startX.value + event.translationX;
translateY.value = startY.value + event.translationY;
})
.onEnd((event) => {
// Velocity-based animation
translateX.value = withDecay({
velocity: event.velocityX,
deceleration: 0.998
});
});
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: translateX.value },
{ translateY: translateY.value }
]
}));
return (
<GestureDetector gesture={panGesture}>
<Animated.View style={[styles.box, animatedStyle]} />
</GestureDetector>
);
```
**Pan gesture configuration methods** control when gestures activate:
- **minDistance(pixels)**: Minimum travel before activation
- **activeOffsetX([min, max])**: Range where gesture activates (prevents conflicts)
- **failOffsetX([min, max])**: Range that fails gesture
- **maxPointers(count)**: Maximum simultaneous touches
**Tap gesture for button feedback:**
```typescript
const scale = useSharedValue(1);
const tapGesture = Gesture.Tap()
.onBegin(() => {
scale.value = withSpring(0.95, { damping: 15, stiffness: 300 });
})
.onFinalize(() => {
scale.value = withSpring(1, { damping: 15, stiffness: 300 });
});
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }]
}));
return (
<GestureDetector gesture={tapGesture}>
<Animated.View style={[styles.button, animatedStyle]}>
<Text>Press Me</Text>
</Animated.View>
</GestureDetector>
);
```
**Gesture composition** enables sophisticated multi-touch interactions using three primitives:
**Gesture.Simultaneous** allows multiple gestures at once (pan + pinch + rotate):
```typescript
const pan = Gesture.Pan()
.averageTouches(true)
.onChange((e) => {
offset.value = { x: e.translationX, y: e.translationY };
});
const pinch = Gesture.Pinch()
.onChange((event) => {
scale.value = savedScale.value * event.scale;
})
.onEnd(() => {
savedScale.value = scale.value;
});
const composed = Gesture.Simultaneous(pan, pinch);
```
**Gesture.Race** activates only first recognized gesture:
```typescript
const drag = Gesture.Pan().onUpdate(/* ... */);
const longPress = Gesture.LongPress().onStart(/* ... */);
const raced = Gesture.Race(drag, longPress);
```
**Gesture.Exclusive** establishes priority order (double-tap before single-tap):
```typescript
const doubleTap = Gesture.Tap()
.numberOfTaps(2)
.onEnd(() => console.log('double'));
const singleTap = Gesture.Tap()
.onEnd(() => console.log('single'));
const taps = Gesture.Exclusive(doubleTap, singleTap);
```
**Critical pattern for travel apps:** Always wrap your app root with GestureHandlerRootView:
```typescript
import { GestureHandlerRootView } from 'react-native-gesture-handler';
export default function App() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
{/* Your app */}
</GestureHandlerRootView>
);
}
```
## Velocity-based animations: snap points, fling detection, and momentum physics
Velocity-based animations preserve momentum from gestures, creating natural continuity between interaction and animation. **The physics: velocity represents initial momentum in points per second, feeding into withDecay for deceleration or withSpring for targeted movement with preserved energy.**
**Fling detection with velocity threshold:**
```typescript
const SCREEN_WIDTH = Dimensions.get('window').width;
const SWIPE_VELOCITY_THRESHOLD = 500; // points per second
const DISTANCE_THRESHOLD = SCREEN_WIDTH * 0.3;
const panGesture = Gesture.Pan()
.onChange((event) => {
translateX.value = event.translationX;
})
.onEnd((event) => {
// Check velocity OR distance
const shouldDismiss =
Math.abs(event.velocityX) > SWIPE_VELOCITY_THRESHOLD ||
Math.abs(translateX.value) > DISTANCE_THRESHOLD;
if (shouldDismiss) {
const direction = translateX.value > 0 ? 1 : -1;
translateX.value = withTiming(
direction * SCREEN_WIDTH * 1.5,
{ duration: 300 },
() => runOnJS(onDismiss)()
);
} else {
translateX.value = withSpring(0, { damping: 15, stiffness: 170 });
}
});
```
**Snap point implementation** combines position and velocity to determine target:
```typescript
const SNAP_POINTS = [0, 200, 400, 600];
function findNearestSnapPoint(currentPos: number, velocity: number): number {
// Project future position based on velocity
const projectedEnd = currentPos + velocity * 0.2;
return SNAP_POINTS.reduce((prev, curr) => {
const prevDist = Math.abs(prev - projectedEnd);
const currDist = Math.abs(curr - projectedEnd);
return currDist < prevDist ? curr : prev;
});
}
const panGesture = Gesture.Pan()
.onChange((event) => {
position.value = event.translationY;
})
.onEnd((event) => {
const target = findNearestSnapPoint(position.value, event.velocityY);
position.value = withSpring(target, {
velocity: event.velocityY, // Preserve momentum
damping: 15,
stiffness: 170
});
});
```
**Bottom sheet with velocity-based snapping** (simplified from @gorhom/react-native-bottom-sheet):
```typescript
const HEIGHT = 300;
const DISMISS_THRESHOLD = HEIGHT / 3;
const offset = useSharedValue(0);
const panGesture = Gesture.Pan()
.onChange((event) => {
const offsetDelta = event.changeY + offset.value;
const clamp = Math.max(-20, offsetDelta);
offset.value = offsetDelta > 0 ? offsetDelta : clamp;
})
.onFinalize((event) => {
const shouldDismiss =
offset.value > DISMISS_THRESHOLD ||
event.velocityY > 500;
if (shouldDismiss) {
offset.value = withTiming(HEIGHT, { duration: 200 });
runOnJS(onRequestDismiss)();
} else {
offset.value = withSpring(0, { damping: 20, stiffness: 200 });
}
});
```
**withDecay for natural deceleration:**
```typescript
const panGesture = Gesture.Pan()
.onChange((event) => {
offset.value += event.changeX;
})
.onFinalize((event) => {
offset.value = withDecay({
velocity: event.velocityX,
deceleration: 0.998,
rubberBandEffect: true,
clamp: [-maxScroll, 0] // Prevent scrolling beyond bounds
});
});
```
**Critical performance insight:** withDecay calculations run entirely on the UI thread through worklets, maintaining 60 FPS even during complex physics calculations that would drop frames on the JS thread.
## Production-ready code examples: modals, buttons, bottom sheets, and swipeable cards
**Modal with backdrop fade and content slide:**
```typescript
import React from 'react';
import { View, Modal, TouchableWithoutFeedback, StyleSheet } from 'react-native';
import Animated, {
FadeIn,
FadeOut,
SlideInDown,
SlideOutDown
} from 'react-native-reanimated';
const AnimatedModal = ({ isVisible, onClose, children }) => {
return (
<Modal transparent visible={isVisible}>
<View style={styles.container}>
<TouchableWithoutFeedback onPress={onClose}>
<Animated.View
entering={FadeIn.duration(300)}
exiting={FadeOut.duration(200)}
style={styles.backdrop}
/>
</TouchableWithoutFeedback>
<Animated.View
entering={SlideInDown.duration(300).damping(20)}
exiting={SlideOutDown.duration(200)}
style={styles.modalContent}
>
{children}
</Animated.View>
</View>
</Modal>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'flex-end',
alignItems: 'center',
},
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
},
modalContent: {
backgroundColor: 'white',
paddingHorizontal: 24,
paddingTop: 24,
paddingBottom: 40,
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
width: '100%',
maxHeight: '90%',
},
});
```
**Button with spring-based scale feedback:**
```typescript
import React from 'react';
import { StyleSheet, Text } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring
} from 'react-native-reanimated';
const AnimatedButton = ({ children, onPress }) => {
const scale = useSharedValue(1);
const tapGesture = Gesture.Tap()
.onBegin(() => {
scale.value = withSpring(0.95, {
damping: 15,
stiffness: 300
});
})
.onFinalize(() => {
scale.value = withSpring(1, {
damping: 15,
stiffness: 300
});
runOnJS(onPress)();
});
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }]
}));
return (
<GestureDetector gesture={tapGesture}>
<Animated.View style={[styles.button, animatedStyle]}>
{children}
</Animated.View>
</GestureDetector>
);
};
const styles = StyleSheet.create({
button: {
paddingHorizontal: 20,
paddingVertical: 12,
backgroundColor: '#007AFF',
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
},
});
```
**Bottom sheet with gesture handler and snap points:**
```typescript
import React, { useRef, useMemo } from 'react';
import BottomSheet, { BottomSheetView } from '@gorhom/react-native-bottom-sheet';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
const TravelBottomSheet = ({ children }) => {
const bottomSheetRef = useRef(null);
const insets = useSafeAreaInsets();
// Snap points accounting for safe area
const snapPoints = useMemo(
() => [
insets.bottom + 150, // Peek height
insets.bottom + 400, // Half height
'90%' // Almost full screen
],
[insets.bottom]
);
return (
<BottomSheet
ref={bottomSheetRef}
index={1}
snapPoints={snapPoints}
enablePanDownToClose
handleIndicatorStyle={{ backgroundColor: '#999' }}
animationConfigs={{
duration: 350,
damping: 80,
stiffness: 500,
}}
>
<BottomSheetView style={{ flex: 1, padding: 20 }}>
{children}
</BottomSheetView>
</BottomSheet>
);
};
```
**Swipeable card with rotation and fling detection:**
```typescript
import React from 'react';
import { Dimensions, StyleSheet } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
interpolate,
runOnJS
} from 'react-native-reanimated';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
const SWIPE_VELOCITY = 500;
const SWIPE_DISTANCE = SCREEN_WIDTH * 0.3;
const SwipeableCard = ({ children, onSwipeLeft, onSwipeRight }) => {
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
const panGesture = Gesture.Pan()
.onChange((event) => {
translateX.value = event.translationX;
translateY.value = event.translationY;
})
.onEnd((event) => {
const shouldS