Skip to content

Gesture-driven animations in React Native that don't drop frames

· 3 min read · Amrith Vengalath

  • React Native
  • Reanimated
  • Animation

The first time I built a swipe-to-dismiss card in React Native, I did it the obvious way: track the finger position in state, update the card's transform on every move. It worked on the simulator and stuttered on a real mid-range Android phone. Classic.

The reason is worth understanding, because it explains why Reanimated exists and why the "obvious" approach is a trap.

Why state-driven animation stutters

React Native runs your JavaScript on one thread and the actual UI on another. When you animate by calling setState on every gesture move, each frame has to: run your JS, send the new position across the bridge to the native UI thread, and render. If the JS thread is busy - and during real app use it often is - frames get dropped, and the animation hitches.

For a 60fps animation you have ~16ms per frame. Round-tripping through the JS thread and the bridge for every one of those is asking for trouble.

What Reanimated changes

Reanimated 2 lets you run animation logic on the UI thread through things called worklets - small functions marked to run natively. Combined with shared values (animated values that live on the UI thread) and the gesture handler, the finger tracking and the transform never touch your JS thread at all. The gesture moves, the value updates, the view re-renders, all natively. Your JavaScript isn't in the hot path.

That's the whole idea: keep the per-frame work off the thread that's busy running your app.

A draggable card, the right way

import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withSpring,
} from "react-native-reanimated";
 
function SwipeCard({ onDismiss }) {
  const translateX = useSharedValue(0);
 
  const pan = Gesture.Pan()
    .onUpdate((e) => {
      // this runs on the UI thread, every frame, no JS bridge hop
      translateX.value = e.translationX;
    })
    .onEnd((e) => {
      if (Math.abs(e.translationX) > 120) {
        translateX.value = withSpring(e.translationX > 0 ? 500 : -500);
        runOnJS(onDismiss)();
      } else {
        translateX.value = withSpring(0); // snap back
      }
    });
 
  const style = useAnimatedStyle(() => ({
    transform: [{ translateX: translateX.value }],
  }));
 
  return (
    <GestureDetector gesture={pan}>
      <Animated.View style={[styles.card, style]} />
    </GestureDetector>
  );
}

A few things to notice, because they're the parts that bit me:

  • onUpdate is a worklet. It runs natively. You can read and write shared values there, but you can't call regular JS functions directly.
  • To call back into JS - like onDismiss, which probably updates React state - you wrap it in runOnJS. Forgetting this and calling a normal function from inside a worklet throws a confusing error. It's the number-one Reanimated mistake.
  • withSpring gives you the snap-back and the fling-away for free, with physics that feel right instead of a linear slide.

The mental model that made it stick

I stopped thinking "update state, re-render." I started thinking "there's a value living on the UI thread, the gesture drives it, and a style reads it." React isn't involved in the animation frame-to-frame; it just sets the whole thing up once. The only time I cross back into JavaScript is for an actual app event, like "the card was dismissed, remove it from the list."

The result on that same mid-range Android phone: smooth. No dropped frames, because the JS thread isn't doing per-frame work anymore.

Worth the extra concepts?

There's a real learning curve - worklets, shared values, runOnJS, the gesture API. For a fade-in you don't need any of it; the basic Animated API or even a layout animation is fine. But the moment a user's finger is driving the animation in real time, this is the difference between "feels native" and "feels like a web page in a wrapper." For gesture-driven UI, it's worth every bit of the ramp-up.