React Native Reanimated
Overview
Reanimated runs animations on the native UI thread, so they stay at 60fps (or the display's refresh rate) even when the JavaScript thread is busy. It uses "worklets": small JavaScript functions that execute on the UI thread via JSI. It is the standard for production-quality animations in React Native: gesture-driven interactions, layout transitions, scroll-based effects, and shared element transitions.
This skill targets Reanimated 4 (4.7.0, September 2026). Version 4 works only on the New Architecture, moves worklets into the separate react-native-worklets package, and adds CSS-style transitions and animations. Apps still on the old architecture stay on 3.x, which is no longer actively maintained.
When to Use
- Any animation in React Native beyond simple opacity/transform
- Gesture-driven interactions (swipe to delete, drag to reorder, pinch to zoom)
- Scroll-driven animations (parallax headers, sticky elements)
- Layout animations (items entering/leaving lists)
- Shared element transitions between screens (experimental in 4.x)
Instructions
Setup
# Expo: installs the versions pinned by the SDK (SDK 57: Reanimated 4.5, Worklets 0.10, Gesture Handler 2.32)
npx expo install react-native-reanimated react-native-worklets react-native-gesture-handler
npx expo prebuild # development builds only: regenerates ios/ and android/
# React Native Community CLI
npm install react-native-reanimated react-native-worklets react-native-gesture-handler
cd ios && pod install && cd ..
npm start -- --reset-cache
- Expo:
babel-preset-expoadds the Worklets Babel plugin by itself; do not list it inbabel.config.js. - Community CLI: add
'react-native-worklets/plugin'as the last entry ofpluginsinbabel.config.js. The Reanimated 3 namereact-native-reanimated/pluginstill resolves in 4.x but is the legacy spelling. - Each Reanimated minor accepts specific
react-native-workletsand React Native versions (4.7 needs Worklets 0.13 and RN 0.86–0.88); check the compatibility table before a manual upgrade. - For gestures, wrap the app root in
GestureHandlerRootViewfromreact-native-gesture-handlerwithstyle={{ flex: 1 }}.
Shared Values and Animated Styles
// components/FadeIn.tsx — Basic animation with shared values
import Animated, { useSharedValue, useAnimatedStyle, withTiming, withSpring } from "react-native-reanimated";
import { type ReactNode, useEffect } from "react";
export function FadeInCard({ children }: { children: ReactNode }) {
const opacity = useSharedValue(0);
const translateY = useSharedValue(20);
useEffect(() => {
opacity.value = withTiming(1, { duration: 600 });
translateY.value = withSpring(0);
}, []);
const animatedStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
transform: [{ translateY: translateY.value }],
}));
return <Animated.View style={[{ backgroundColor: "white", padding: 16 }, animatedStyle]}>{children}</Animated.View>;
}
Read and write .value only in effects, event handlers and worklets, never during render. With the React Compiler use opacity.get() and opacity.set(1) instead of .value.
Gesture Animations
// components/SwipeToDelete.tsx — Swipe gesture with animation
import Animated, { useSharedValue, useAnimatedStyle, withSpring } from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { scheduleOnRN } from "react-native-worklets";
import type { ReactNode } from "react";
export function SwipeToDelete({ onDelete, children }: { onDelete: () => void; children: ReactNode }) {
const translateX = useSharedValue(0);
const pan = Gesture.Pan()
.onUpdate((event) => {
translateX.value = Math.min(0, event.translationX); // only allow left swipe
})
.onEnd((event) => {
if (event.translationX < -150) {
translateX.value = withSpring(-400);
scheduleOnRN(onDelete); // call a JS-thread function from the UI thread
} else {
translateX.value = withSpring(0); // snap back
}
});
const animatedStyle = useAnimatedStyle(() => ({ transform: [{ translateX: translateX.value }] }));
return (
<GestureDetector gesture={pan}>
<Animated.View style={animatedStyle}>{children}</Animated.View>
</GestureDetector>
);
}
scheduleOnRN(fn, ...args) replaces runOnJS(fn)(...args), which is deprecated in 4.x. Gesture.Pan() is the Gesture Handler 2 builder API, the one Expo SDK 57 installs; Gesture Handler 3 keeps it and adds hooks (usePanGesture({ onUpdate, onDeactivate }), where onStart/onEnd are named onActivate/onDeactivate).
Layout Animations
// components/AnimatedList.tsx — Items animate in/out automatically
import Animated, { FadeInDown, FadeOutLeft, LinearTransition } from "react-native-reanimated";
import { Pressable, Text } from "react-native";
type Item = { id: string; title: string };
export function AnimatedList({ items, onRemove }: { items: Item[]; onRemove: (id: string) => void }) {
return (
<Animated.FlatList
data={items}
keyExtractor={(item) => item.id}
itemLayoutAnimation={LinearTransition} // remaining rows slide into place; single column only
renderItem={({ item, index }) => (
<Animated.View
entering={FadeInDown.delay(index * 100).springify()}
exiting={FadeOutLeft.duration(300)}
style={{ backgroundColor: "white", padding: 16, marginVertical: 4, borderRadius: 8 }}
>
<Text>{item.title}</Text>
<Pressable onPress={() => onRemove(item.id)}><Text style={{ color: "#dc2626" }}>Remove</Text></Pressable>
</Animated.View>
)}
/>
);
}
Scroll-Driven Animations
// components/ParallaxHeader.tsx — Parallax effect on scroll
import Animated, {
useAnimatedScrollHandler, useSharedValue, useAnimatedStyle, interpolate, Extrapolation,
} from "react-native-reanimated";
import { Text } from "react-native";
export function ParallaxHeader() {
const scrollY = useSharedValue(0);
const scrollHandler = useAnimatedScrollHandler({
onScroll: (event) => { scrollY.value = event.contentOffset.y; },
});
const headerStyle = useAnimatedStyle(() => ({
height: interpolate(scrollY.value, [-100, 0, 200], [400, 300, 100], Extrapolation.CLAMP),
opacity: interpolate(scrollY.value, [0, 200], [1, 0.3], Extrapolation.CLAMP),
}));
return (
<>
<Animated.View style={[{ backgroundColor: "#2563eb" }, headerStyle]}>
<Text style={{ color: "white", fontSize: 28, fontWeight: "bold" }}>Trail Log</Text>
</Animated.View>
<Animated.ScrollView onScroll={scrollHandler}>{/* Content */}</Animated.ScrollView>
</>
);
}
When only the offset is needed, useScrollOffset(animatedRef) (named useScrollViewOffset in 3.x) returns it as a shared value without a handler.
CSS Transitions and Shared Elements (Reanimated 4)
// a state change animates by itself: no shared value, no hook
<Animated.View
style={{
height: 80,
width: expanded ? 240 : 120,
backgroundColor: expanded ? "#16a34a" : "#2563eb",
transitionProperty: ["width", "backgroundColor"],
transitionDuration: 300,
}}
/>
// the same tag on two screens of a native stack animates the element between them
<Animated.Image sharedTransitionTag="trail-42-photo" source={{ uri: photoUrl }} style={{ width: 120, height: 120 }} />
Shared element transitions are experimental in 4.x and off by default. Enable them with "reanimated": { "staticFeatureFlags": { "ENABLE_SHARED_ELEMENT_TRANSITIONS": true } } in the app's package.json (4.2+), then rebuild the native app; static flags cannot be changed in Expo Go.
Examples
Example 1: Build a card swipe interface
User prompt: "Build a Tinder-style card swipe interface with smooth animations."
// components/SwipeCard.tsx
import Animated, { interpolate, useAnimatedStyle, useSharedValue, withSpring, withTiming } from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { scheduleOnRN } from "react-native-worklets";
import { useWindowDimensions } from "react-native";
import type { ReactNode } from "react";
type Props = { children: ReactNode; onSwipe: (direction: "left" | "right") => void };
export function SwipeCard({ children, onSwipe }: Props) {
const { width } = useWindowDimensions();
const x = useSharedValue(0);
const y = useSharedValue(0);
const pan = Gesture.Pan()
.onUpdate((e) => { x.value = e.translationX; y.value = e.translationY; })
.onEnd((e) => {
if (Math.abs(e.translationX) > width * 0.3) {
const direction = e.translationX > 0 ? "right" : "left";
x.value = withTiming(Math.sign(e.translationX) * width * 1.5, { duration: 200 }, (finished) => {
if (finished) scheduleOnRN(onSwipe, direction); // remove the card in React state
});
} else {
x.value = withSpring(0);
y.value = withSpring(0);
}
});
const style = useAnimatedStyle(() => ({
transform: [{ translateX: x.value }, { translateY: y.value }, { rotate: `${interpolate(x.value, [-width, 0, width], [-15, 0, 15])}deg` }],
}));
return (
<GestureDetector gesture={pan}>
<Animated.View style={style}>{children}</Animated.View>
</GestureDetector>
);
}
Result: the card follows the finger and tilts up to 15°. Released past 30% of the screen width it flies off and onSwipe("left" | "right") runs on the JS thread; otherwise it springs back to the centre.
Example 2: Animated bottom sheet
User prompt: "Create a bottom sheet that can be dragged up and snaps to positions."
// components/BottomSheet.tsx
import Animated, { useAnimatedStyle, useSharedValue, withSpring } from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { useWindowDimensions } from "react-native";
import type { ReactNode } from "react";
export function BottomSheet({ children }: { children: ReactNode }) {
const { height } = useWindowDimensions();
const snapPoints = [height * 0.1, height * 0.5, height * 0.85]; // top edge: open, half, peek
const top = useSharedValue(snapPoints[2]);
const start = useSharedValue(0);
const pan = Gesture.Pan()
.onBegin(() => { start.value = top.value; })
.onUpdate((e) => { top.value = Math.max(snapPoints[0], start.value + e.translationY); })
.onEnd((e) => {
const projected = top.value + e.velocityY * 0.2; // where the fling would land
const target = snapPoints.reduce((a, b) => (Math.abs(b - projected) < Math.abs(a - projected) ? b : a));
top.value = withSpring(target, { velocity: e.velocityY });
});
const style = useAnimatedStyle(() => ({ transform: [{ translateY: top.value }] }));
const sheet = { position: "absolute", left: 0, right: 0, top: 0, height, backgroundColor: "white" } as const;
return (
<GestureDetector gesture={pan}>
<Animated.View style={[sheet, style]}>{children}</Animated.View>
</GestureDetector>
);
}
Result: the sheet starts in the peek position, follows the drag, and on release springs to the nearest of the three snap points, taking the fling velocity into account.
Guidelines
- Shared values for animation state —
useSharedValuelives on the UI thread; changing it does not re-render the component useAnimatedStylefor dynamic styles — recalculated on the UI threadwithSpringfor natural motion —withTimingfor precise duration. Version 4 changed the spring defaults (damping 120, stiffness 900) and treatsdurationas perceptual;Reanimated3DefaultSpringConfigrestores the old feelscheduleOnRNto call JS from worklets — pass a function defined in the component or module scope, not one created inside the worklet- Gesture Handler integration —
Gesture.Pan(),Gesture.Pinch(), etc.; callbacks passed inline are workletized automatically, a callback stored in a variable needs a'worklet';directive - Layout animations are declarative —
entering,exitingprops on Animated components interpolatefor value mapping — map scroll position to opacity, scale, etc.; passExtrapolation.CLAMPto stop at the range ends- Worklets Babel plugin must be last — and after changing Babel config, clear the Metro cache
- Don't capture large objects in worklets — captured values are copied to the UI runtime; pass shared values, primitives and small plain objects
- Upgrading from 3.x — enable the New Architecture first, install
react-native-worklets, rename the Babel plugin, and replaceuseAnimatedGestureHandler,useWorkletCallbackandcombineTransition, which were removed - When not to use — a one-off fade or slide that never blocks can stay on React Native's built-in
AnimatedAPI (CSS transitions on native come from Reanimated itself); Reanimated 4 cannot be installed in an app on the old architecture