Combining Gesture Handler and Reanimated for Complex Interactions
AI generated
RN
native
React Native · Gesture Handler · Reanimated
Combining Gesture Handler and Reanimated
Building complex interactions with zero bridge overhead

Gestures alone only deliver raw data, real interactions only emerge once that data flows into animations in real time. This article shows how react-native-gesture-handler and Reanimated work together through shared values, so pan and pinch gestures turn into fluid animations directly and synchronously on the UI thread, illustrated with a swipeable card that springs back into place.

13 min read Gesture.Pan Shared values withSpring

1. Why gestures and animations belong together on the UI thread

A gesture without direct feedback immediately feels wrong to users. If there is even a few milliseconds of delay between the finger on the screen and the visual response, say because the value first has to travel over the bridge to the JS thread and back, a card being dragged feels sticky instead of direct. Gesture Handler and Reanimated solve this together, because both libraries sit on the same JSI foundation.

Without this combination, every finger movement would need to be sent as an event over the bridge to the JS thread, processed there, and sent back as a new style value, which noticeably lags behind at 60 or more events per second. With Gesture Handler and Reanimated, the entire chain from finger movement to drawn frame stays on the UI thread, without a single bridge round trip per frame.

2. An overview of react-native-gesture-handler's Gesture API

Since version 2, react-native-gesture-handler ships a declarative Gesture API, where gestures like Gesture.Pan(), Gesture.Pinch(), or Gesture.Tap() get configured through callback methods such as onStart, onUpdate, and onEnd. These callbacks are automatically treated as worklets as long as they are wired up through a GestureDetector, which lets them run directly on the UI thread.

Several gestures can be combined into more complex recognition rules through Gesture.Simultaneous(), Gesture.Race(), or Gesture.Exclusive(), for example allowing pan and pinch gestures at the same time while only recognizing a tap when neither of the other two gestures has become active.


const pan = Gesture.Pan()
  .onStart(() => {
    scale.value = withTiming(1.05);
  })
  .onUpdate((event) => {
    translateX.value = event.translationX;
    translateY.value = event.translationY;
  })
  .onEnd(() => {
    scale.value = withTiming(1);
  });

3. Shared values as the bridge between gesture and animation

The core of the combination is that a gesture writes its raw data, say translationX or scale, directly into a shared value, without a detour through React state. Every useAnimatedStyle calculation that depends on it then reacts in the very same frame the shared value changes, because both sides run on the same UI thread.

This direct coupling also means no extra synchronization logic is needed. Unlike a classic solution built on PanResponder and Animated.Value, there is no useNativeDriver flag to set and no limited property list to respect here, every property driven through useAnimatedStyle automatically benefits from the same performance.

4. Practical example: dragging a card with a pan gesture

The following example shows a card that can be dragged horizontally with a pan gesture and rotates slightly, proportional to the horizontal offset. The rotation is derived directly from the same shared value used for the translation, through interpolate(), so no second gesture or second listener is needed.

It matters that context values, meaning the state at the start of the gesture, are held through useSharedValue rather than plain variables, because worklets are re-evaluated on every frame and local variables are not automatically preserved between two frames.


const translateX = useSharedValue(0);
const startX = useSharedValue(0);

const pan = Gesture.Pan()
  .onStart(() => {
    startX.value = translateX.value;
  })
  .onUpdate((event) => {
    translateX.value = startX.value + event.translationX;
  })
  .onEnd(() => {
    translateX.value = withSpring(0);
  });

const cardStyle = useAnimatedStyle(() => ({
  transform: [
    { translateX: translateX.value },
    { rotateZ: `${interpolate(translateX.value, [-200, 200], [-15, 15])}deg` },
  ],
}));

5. The spring-back effect with withSpring and threshold checks

A spring-back effect emerges when the gesture is released and animated with withSpring instead of withTiming, because spring animations naturally overshoot and settle in a way that feels physically correct. The config parameters damping, stiffness, and mass let you fine-tune the behavior from tight and snappy to soft and slow settling.

In practice, a threshold check usually gets added as well: if the offset at release exceeds a certain value, say a third of the card's width, the card animates fully off screen and a runOnJS call informs the JS side that the card was removed. If the offset stays below that, the card springs back to its starting position via withSpring.


.onEnd((event) => {
  const shouldDismiss = Math.abs(translateX.value) > CARD_WIDTH / 3;
  if (shouldDismiss) {
    translateX.value = withSpring(
      Math.sign(translateX.value) * CARD_WIDTH * 1.5,
      { velocity: event.velocityX },
      () => runOnJS(onCardDismissed)(),
    );
  } else {
    translateX.value = withSpring(0, { damping: 15, stiffness: 150 });
  }
});

6. Combining pinch gestures for zoom interactions

A pinch gesture reports a relative scale factor through event.scale, measured from the start of the gesture. As with the pan gesture, the starting value is stored in its own shared value, so several consecutive pinch gestures build on each other correctly instead of resetting to one on every new gesture.

Combining pinch with pan through Gesture.Simultaneous() lets you build image viewers or map views where zooming and panning happen at the same time, a pattern that would be hard to implement without jank without the tight coupling between Gesture Handler and Reanimated.


const scale = useSharedValue(1);
const startScale = useSharedValue(1);

const pinch = Gesture.Pinch()
  .onStart(() => {
    startScale.value = scale.value;
  })
  .onUpdate((event) => {
    scale.value = startScale.value * event.scale;
  })
  .onEnd(() => {
    scale.value = withSpring(Math.max(1, Math.min(scale.value, 4)));
  });

const combined = Gesture.Simultaneous(pan, pinch);

7. Common pitfalls when combining the two

A widespread mistake is keeping the state for the gesture start in a plain JavaScript variable instead of a shared value. Since gesture callbacks run as worklets on the UI thread, such a variable behaves like a fresh, independent copy on every callback, which loses the starting value between consecutive gestures.

A second pitfall is calling runOnJS inside onUpdate, meaning on every single frame of a running gesture, instead of only in onStart or onEnd. That floods the JS thread with messages and, ironically, produces exactly the jank that combining Gesture Handler and Reanimated is meant to avoid.

8. A reusable hook: useSwipeableCard()

Once several card types need the same swipe-and-spring-back logic, it is worth extracting the gesture, shared values, and style into a dedicated hook that returns a ready-made pair of gesture object and animated style. The calling component then only needs to wire up the hook and attach the returned style to an Animated.View, without knowing the gesture details itself.

This encapsulation also makes testing easier: pure calculation logic, say converting offset into a rotation angle or checking the dismissal threshold, can be extracted into a standalone function independent of worklets and covered with regular Jest tests, while the hook itself only handles the wiring to Gesture Handler and Reanimated.


function useSwipeableCard(cardWidth: number, onDismissed: () => void) {
  const translateX = useSharedValue(0);
  const startX = useSharedValue(0);

  const gesture = Gesture.Pan()
    .onStart(() => {
      startX.value = translateX.value;
    })
    .onUpdate((event) => {
      translateX.value = startX.value + event.translationX;
    })
    .onEnd((event) => {
      const dismiss = Math.abs(translateX.value) > cardWidth / 3;
      translateX.value = dismiss
        ? withSpring(Math.sign(translateX.value) * cardWidth * 1.5, {
            velocity: event.velocityX,
          }, () => runOnJS(onDismissed)())
        : withSpring(0, { damping: 15, stiffness: 150 });
    });

  const style = useAnimatedStyle(() => ({
    transform: [
      { translateX: translateX.value },
      { rotateZ: `${interpolate(translateX.value, [-cardWidth, cardWidth], [-15, 15])}deg` },
    ],
  }));

  return { gesture, style };
}

9. The difference from the classic Animated API with useNativeDriver

Before Reanimated and the new Gesture API, the common solution was a combination of PanResponder and Animated.Value with useNativeDriver: true. That worked reasonably well for simple transform and opacity animations, but was limited to exactly that property list and could not be combined with arbitrary JavaScript logic inside the animation.

Gesture Handler and Reanimated remove that limitation entirely, since worklets can run arbitrary calculations directly on the UI thread, not just a fixed list of supported properties. That is what makes complex, conditional interactions like the swipeable card with a spring-back effect practical to build in the first place.

Interaction Gesture Animation API Key parameter
Drag a card horizontally Gesture.Pan() useAnimatedStyle translationX
Spring back on release onEnd withSpring damping, stiffness
Dismiss a card onEnd with a threshold withSpring + runOnJS velocity
Zoom an image Gesture.Pinch() useAnimatedStyle event.scale
Zoom and pan at the same time Gesture.Simultaneous() useAnimatedStyle separate shared values per gesture
Rotation proportional to offset Gesture.Pan() interpolate() input and output range

Mironsoft

React Native app development and Magento integration

A mobile app for the Magento shop that actually runs smoothly?

We build React Native apps cleanly connected to the Magento REST or GraphQL API, from the first line of code to publishing on the App Store and Google Play.

App Concept

Plan the architecture and feature scope of a Magento-connected app together.

Magento API Integration

Cleanly connect product catalog, cart, and checkout to the shop API.

Store Publishing

Guide the App Store and Google Play release process without pitfalls.

10. Summary

Gesture Handler and Reanimated

Core idea

Gestures write their raw data directly into shared values, so animations react in the same frame with zero bridge round trip.

Spring-back effect

withSpring with the right damping and stiffness values produces a natural settle on release.

Biggest pitfall

Gesture-start values stored in plain variables instead of shared values get lost between consecutive gestures.

Composability

Gesture.Simultaneous() allows pan and pinch at once, each gesture with its own pair of shared values.

11. FAQ: Gesture Handler and Reanimated

1Why are Gesture Handler and Reanimated faster than PanResponder with Animated?
Because both libraries build on JSI and run their callbacks as worklets directly on the UI thread, while PanResponder sends events over the classic bridge to the JS thread, which causes noticeable delay at 60 events per second.
2Do I need a GestureDetector for every single gesture?
Yes, every gesture definition must be wired to a GestureDetector so react-native-gesture-handler takes over touch recognition for that area and registers the callbacks as worklets.
3How do I correctly capture a gesture's starting value?
Through a dedicated shared value that gets filled with the current value of the target shared value inside onStart. Plain JavaScript variables do not work reliably for this, since worklet callbacks are evaluated in isolation on every call.
4How do I create a natural spring-back effect?
With withSpring instead of withTiming, combined with sensible damping and stiffness values. Lower damping values produce more visible overshoot, higher stiffness values produce faster movement.
5When should I call runOnJS inside a gesture?
Only at clearly defined transitions like onStart or onEnd, never inside onUpdate, otherwise a message gets sent to the JS thread on every single frame and performance suffers.
6How do I combine pan and pinch gestures at the same time?
Through Gesture.Simultaneous(pan, pinch), where each gesture should use its own shared values for the start and current value so the two gestures do not overwrite each other.
7How do I determine the threshold for dismissing a card?
A common choice is a fraction of the card width, say a third, combined with the release velocity from event.velocityX, so fast, short swipes get recognized reliably too.
8Do I still need useNativeDriver with this combination?
No, useNativeDriver is a concept from the classic Animated API and is not needed with Reanimated, since every property driven through useAnimatedStyle automatically runs on the UI thread.
9Can I derive rotation from the same gesture used for the offset?
Yes, interpolate() lets you compute a rotation angle directly from the same shared value used for the translation, without needing a second gesture or a second shared value.
10Does this combination work for lists with many swipeable cards?
Yes, as long as every card owns its own shared values instead of sharing them globally. For very long lists, it also helps to remove cards outside the visible area through a virtualized list.