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.
Table of Contents
- 1. Why gestures and animations belong together on the UI thread
- 2. An overview of react-native-gesture-handler's Gesture API
- 3. Shared values as the bridge between gesture and animation
- 4. Practical example: dragging a card with a pan gesture
- 5. The spring-back effect with withSpring and threshold checks
- 6. Combining pinch gestures for zoom interactions
- 7. Common pitfalls when combining the two
- 8. A reusable hook: useSwipeableCard()
- 9. The difference from the classic Animated API with useNativeDriver
- 10. Summary
- 11. FAQ
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.