The one rule: animations belong on the UI thread
A React Native app runs your code on the JavaScript thread and draws on the UI thread. If the animation is computed in JavaScript, then every frame depends on the JS thread being free at that exact moment. It usually is — right up until the user does something interesting, like scrolling a list while a response lands. Then the JS thread is busy, frames get skipped, and the animation stutters at precisely the moment the user was paying attention.
react-native-reanimated solves this by compiling small functions called workletsthat run on the UI thread. The animation keeps producing frames whether or not JavaScript is busy. That’s the entire pitch, and it’s enough of one that Reanimated is now the default in essentially every serious React Native codebase — including the navigation and gesture libraries you’re probably already using.
npx expo install react-native-reanimatedThe core pattern: shared value → animated style
Reanimated has one idea you need to hold: a shared value is a number that lives outside React. Changing it does notre-render the component — it updates a style directly on the UI thread. Here’s a press-to-scale button, which is the smallest useful example:
import { Pressable, Text } from 'react-native';
import Animated, {
useSharedValue, useAnimatedStyle, withSpring,
} from 'react-native-reanimated';
export default function ScaleButton({ onPress, label }) {
const scale = useSharedValue(1);
// this callback is a worklet — it runs on the UI thread
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
return (
<Pressable
onPressIn={() => { scale.value = withSpring(0.95, { damping: 15 }); }}
onPressOut={() => { scale.value = withSpring(1, { damping: 15 }); }}
onPress={onPress}
>
<Animated.View style={[styles.button, animatedStyle]}>
<Text style={styles.label}>{label}</Text>
</Animated.View>
</Pressable>
);
}Three things are load-bearing there:
- It’s
Animated.View, notView. A plain View has nowhere to receive the UI-thread updates, and your animation silently does nothing. - You read and write
scale.value, neverscaleitself. withSpringis physics, not a duration.withTimingtakes a duration and easing when you want an exact length — fades and colour changes usually do.
Animate these, not those
| Property | Per-frame cost | Why | Verdict |
|---|---|---|---|
| transform (translate, scale, rotate) | Cheap | Composited — no layout pass | Animate freely |
| opacity | Cheap | Composited | Animate freely |
| backgroundColor / borderColor | Moderate | Repaint, no reflow | Fine in moderation |
| width / height / padding / margin | Expensive | Triggers layout on every frame | Use scale, or a layout animation |
| flex / position values | Expensive | Re-lays out siblings too | Avoid per-frame |
The practical version: if you find yourself animating height to expand a card, reach for scaleor a layout animation instead. A growing height re-lays out everything below it sixty times a second; a scale transform doesn’t touch layout at all.
Free wins: entering, exiting, and layout
Most of the motion in a polished app isn’t hand-built — it’s mount, unmount, and reflow. Those are one prop each:
import Animated, {
FadeIn, FadeOut, LinearTransition,
} from 'react-native-reanimated';
{items.map((item, i) => (
<Animated.View
key={item.id}
entering={FadeIn.delay(i * 40)} // staggered arrival
exiting={FadeOut}
layout={LinearTransition} // siblings slide as one is removed
>
<ItemRow item={item} />
</Animated.View>
))}layout is the underrated one. Delete a row from a list without it and everything below teleports upward; with it, the gap closes smoothly and the change reads as an event rather than a glitch.
Keep the stagger delay small. Forty milliseconds per item feels alive; two hundred feels like the app is making you wait, and past about eight items you should stagger only the first few and let the rest arrive together.
Gestures: when the finger drives
A swipe-to-dismiss card is the canonical case where JS-thread animation visibly fails — any lag between finger and card is instantly obvious. react-native-gesture-handlerreads the touch natively and writes straight into a shared value, so there’s no round trip:
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
useSharedValue, useAnimatedStyle, withSpring, runOnJS,
} from 'react-native-reanimated';
function SwipeableCard({ onDismiss, children }) {
const x = useSharedValue(0);
const pan = Gesture.Pan()
.onUpdate((e) => { x.value = e.translationX; })
.onEnd((e) => {
if (Math.abs(e.translationX) > 120) {
x.value = withSpring(Math.sign(e.translationX) * 500);
runOnJS(onDismiss)(); // crossing back to the JS thread
} else {
x.value = withSpring(0);
}
});
const style = useAnimatedStyle(() => ({
transform: [{ translateX: x.value }],
opacity: 1 - Math.min(Math.abs(x.value) / 300, 0.6),
}));
return (
<GestureDetector gesture={pan}>
<Animated.View style={style}>{children}</Animated.View>
</GestureDetector>
);
}runOnJS is the piece people forget. Gesture callbacks are worklets on the UI thread — calling a normal React callback such as onDismiss directly from one throws. Wrap it, and only for things that genuinely need React: state updates, navigation, network calls. Everything visual should stay on the UI thread.
Judging motion honestly
- Test in release mode on hardware.Dev builds run unoptimised JavaScript, and the iOS simulator draws on your Mac’s GPU. Both lie in opposite directions.
- Test on the cheapest Android you own. That device is your real frame budget.
- Respect reduce-motion. Read
AccessibilityInfo.isReduceMotionEnabled()and shorten or drop non-essential motion. Some users get motion sick; App Store reviewers occasionally check. - Under 300ms for interface motion. Anything longer stops reading as feedback and starts reading as latency.
For everything around animation that affects perceived speed — list virtualisation, image decoding, re-render churn — the performance fixes guide covers the rest of the frame budget.
The shortcut: generate the motion, tune the feel
The Reanimated setup — installing it, wiring shared values, remembering Animated.View and runOnJS— is mechanical. What isn’t mechanical is deciding that this spring is a touch too bouncy, which you can only do by watching it on a phone.
Describe your app in ShipNative and it generates the screens with Reanimated already wired — press feedback, list entrances, screen transitions — running live on your device, so tuning is a prompt away rather than a rebuild away. Then export the whole Expo project and take the motion with you.
Build an animated app free
Describe your app in one sentence and watch it move on your own phone in minutes at shipnative.dev. No credit card, full React Native source export whenever you want it.