React Native Reanimated and Gesture Handler patterns
React Native animations have a reputation for being janky. The reputation is earned — but only when you use the wrong tools. React Native Reanimated 3 and Gesture Handler run animations and gesture recognition on the UI thread, bypassing the JS bridge entirely. The result is 60fps gestures even when the JS thread is busy. Here are the patterns I use most.
The core concepts to understand
Two concepts make everything else click:
- Shared values: Mutable state that lives on the UI thread. Changes to shared values trigger re-renders without going through the JS bridge.
- Worklets: Functions that run on the UI thread. Annotated with
'worklet'. Cannot call regular JS (no async, no state updates). All Reanimated callbacks are worklets.
Pattern 1: Swipeable card (left/right swipe to action)
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
runOnJS,
} from 'react-native-reanimated';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { Dimensions } from 'react-native';
const SCREEN_WIDTH = Dimensions.get('window').width;
const SWIPE_THRESHOLD = SCREEN_WIDTH * 0.4;
interface SwipeCardProps {
onSwipeLeft: () => void;
onSwipeRight: () => void;
children: React.ReactNode;
}
export function SwipeCard({ onSwipeLeft, onSwipeRight, children }: SwipeCardProps) {
const translateX = useSharedValue(0);
const rotate = useSharedValue(0);
const gesture = Gesture.Pan()
.onUpdate((event) => {
translateX.value = event.translationX;
rotate.value = (event.translationX / SCREEN_WIDTH) * 15; // max 15 degrees
})
.onEnd((event) => {
if (event.translationX > SWIPE_THRESHOLD) {
translateX.value = withSpring(SCREEN_WIDTH * 1.5);
runOnJS(onSwipeRight)();
} else if (event.translationX < -SWIPE_THRESHOLD) {
translateX.value = withSpring(-SCREEN_WIDTH * 1.5);
runOnJS(onSwipeLeft)();
} else {
// Snap back
translateX.value = withSpring(0);
rotate.value = withSpring(0);
}
});
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: translateX.value },
{ rotate: `${rotate.value}deg` },
],
}));
return (
<GestureDetector gesture={gesture}>
<Animated.View style={animatedStyle}>
{children}
</Animated.View>
</GestureDetector>
);
}
Note the runOnJS(onSwipeLeft)() call. Gesture callbacks run on the UI thread. To call a regular JS function (state updates, navigation, API calls), you must wrap it in runOnJS.
Pattern 2: Pull-to-refresh with custom animation
import Animated, {
useSharedValue,
useAnimatedStyle,
useAnimatedScrollHandler,
interpolate,
Extrapolation,
} from 'react-native-reanimated';
const REFRESH_THRESHOLD = 80;
export function PullToRefresh({ onRefresh, children }) {
const scrollY = useSharedValue(0);
const isRefreshing = useSharedValue(false);
const scrollHandler = useAnimatedScrollHandler({
onScroll: (event) => {
scrollY.value = event.contentOffset.y;
},
});
const indicatorStyle = useAnimatedStyle(() => {
const pullDistance = Math.max(0, -scrollY.value);
const opacity = interpolate(
pullDistance,
[0, REFRESH_THRESHOLD],
[0, 1],
Extrapolation.CLAMP,
);
const scale = interpolate(
pullDistance,
[0, REFRESH_THRESHOLD],
[0.5, 1],
Extrapolation.CLAMP,
);
return { opacity, transform: [{ scale }] };
});
return (
<>
<Animated.View style={[styles.indicator, indicatorStyle]}>
<ActivityIndicator />
</Animated.View>
<Animated.ScrollView onScroll={scrollHandler} scrollEventThrottle={16}>
{children}
</Animated.ScrollView>
</>
);
}
Pattern 3: Bottom sheet with snap points
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
runOnJS,
} from 'react-native-reanimated';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
const SNAP_POINTS = [0, 300, 600]; // px from bottom
export function BottomSheet({ children }) {
const translateY = useSharedValue(SNAP_POINTS[0]);
const startY = useSharedValue(0);
const gesture = Gesture.Pan()
.onBegin(() => {
startY.value = translateY.value;
})
.onUpdate((event) => {
const newY = startY.value - event.translationY;
translateY.value = Math.max(0, Math.min(SNAP_POINTS[2], newY));
})
.onEnd((event) => {
// Snap to nearest snap point
const currentY = translateY.value;
const closest = SNAP_POINTS.reduce((prev, curr) =>
Math.abs(curr - currentY) < Math.abs(prev - currentY) ? curr : prev
);
translateY.value = withSpring(closest, {
velocity: event.velocityY * -1,
damping: 20,
});
});
const sheetStyle = useAnimatedStyle(() => ({
transform: [{ translateY: -translateY.value }],
}));
return (
<GestureDetector gesture={gesture}>
<Animated.View style={[styles.sheet, sheetStyle]}>
<View style={styles.handle} />
{children}
</Animated.View>
</GestureDetector>
);
}
Pattern 4: Spring on mount (entrance animation)
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withDelay,
} from 'react-native-reanimated';
import { useEffect } from 'react';
export function AnimatedCard({ delay = 0, children }) {
const scale = useSharedValue(0.8);
const opacity = useSharedValue(0);
useEffect(() => {
scale.value = withDelay(delay, withSpring(1, { damping: 12 }));
opacity.value = withDelay(delay, withSpring(1));
}, []);
const style = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
opacity: opacity.value,
}));
return <Animated.View style={style}>{children}</Animated.View>;
}
// Staggered list entrance
function CardList({ items }) {
return items.map((item, index) => (
<AnimatedCard key={item.id} delay={index * 60}>
<Card item={item} />
</AnimatedCard>
));
}
The mistake that kills performance
The most common performance mistake: calling state setters inside animation callbacks. This triggers a JS re-render on every frame, destroying the 60fps you were trying to achieve.
// BAD: setState inside onUpdate triggers JS re-render every frame
const [x, setX] = useState(0);
const gesture = Gesture.Pan().onUpdate((event) => {
runOnJS(setX)(event.translationX); // This runs at 60fps — kills performance
});
// GOOD: use shared value for animation, state only for final result
const translateX = useSharedValue(0);
const gesture = Gesture.Pan()
.onUpdate((event) => {
translateX.value = event.translationX; // UI thread only
})
.onEnd((event) => {
runOnJS(setFinalPosition)(event.translationX); // State update only on release
});
Keep shared values for animation state. Call JS (state, navigation, API) only at gesture boundaries (onBegin, onEnd) — never in onUpdate.