●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
Rork × React Native Reanimated & Gesture Handler — Building 60fps Animations and Advanced Gesture Interactions
Keeping Reanimated 3 and Gesture Handler at 60fps, told through the production pitfalls I hit running wallpaper and calming-tone apps: delayed runOnJS, useDerivedValue caching, FlashList pairing, and missing cancelAnimation — each with real measurements.
Setup and context — Why Reanimated and Gesture Handler Matter
One of the defining factors of a high-quality mobile app is the smoothness of its animations and the responsiveness of its gesture handling. Users perform scrolls, swipes, and pinch-to-zoom gestures instinctively — even the slightest stutter or delay creates a perception that the app is poorly built.
React Native's built-in Animated API handles basic animations well enough, but it falls short when you need complex gesture-driven animations or consistent 60fps transitions. This is where React Native Reanimated 3 and React Native Gesture Handler come in.
Reanimated runs animation logic directly on the UI thread (native side), completely bypassing JavaScript thread bottlenecks. Gesture Handler recognizes gestures at the native level, and when combined with Reanimated, delivers the kind of "sticks to your finger" responsiveness that users expect from premium apps.
I'm Masaki Hirokawa, an indie developer who has been running iOS and Android apps on my own since 2013 — mostly quiet utilities in the wallpaper, calming-tone, and intention categories, around 50 million cumulative downloads, that people open a little bit every day. (I also run the Dolice Labs sites.) Apps like these don't need flashy effects, but a single hitch in a swipe or pinch translates directly into churn. After shipping Reanimated and Gesture Handler into real products, I kept running into cases that follow the docs perfectly yet break only in production. This guide covers the core architecture and four implementation patterns, and then the "things the docs won't tell you" — each backed by real numbers.
Reanimated 3 Architecture — Understanding Worklets and Shared Values
Why the Standard Animated API Isn't Enough
React Native's standard Animated API computes animation values on the JS thread and sends them to the UI thread via the bridge. Under this architecture, when the JS thread is busy with heavy operations (API calls, data transformations, etc.), animations stutter.
Reanimated 3 solves this with Worklets — JavaScript functions that execute directly on the UI thread. Simply adding the 'worklet' directive at the top of a function causes it to be serialized and run on the UI thread automatically.
Shared Values — Fast Cross-Thread Data Sharing
Values created with useSharedValue can be safely accessed from both the JS and UI threads. Because they bypass the traditional bridge, value updates are reflected without frame drops.
import Animated, { useSharedValue, useAnimatedStyle, withSpring, withTiming, Easing,} from 'react-native-reanimated';// Inside a component// Create a Shared Value (accessible from both UI and JS threads)const translateX = useSharedValue(0);const scale = useSharedValue(1);// Declaratively define animated styles// This function runs on the UI thread, avoiding JS thread blockingconst animatedStyle = useAnimatedStyle(() => ({ transform: [ { translateX: translateX.value }, { scale: scale.value }, ],}));// Update values with spring animation// damping: oscillation decay rate, stiffness: spring tensionconst handlePress = () => { translateX.value = withSpring(200, { damping: 15, // Lower = more bounce stiffness: 150, // Higher = faster motion mass: 1, // Mass (affects inertia) }); scale.value = withTiming(1.2, { duration: 300, easing: Easing.bezier(0.25, 0.1, 0.25, 1), });};// Usage in JSX// <Animated.View style={[styles.box, animatedStyle]} />
Derived Values — Computed Animation Values
useDerivedValue lets you create computed values that automatically update on the UI thread based on other Shared Values.
import { useDerivedValue, interpolate } from 'react-native-reanimated';// Auto-compute rotation based on translateXconst rotation = useDerivedValue(() => { // Map translateX range -200..200 to rotation -30..30 degrees return interpolate( translateX.value, [-200, 0, 200], [-30, 0, 30], 'clamp' // Clamp values outside range );});// Opacity also changes in syncconst opacity = useDerivedValue(() => { return interpolate( Math.abs(translateX.value), [0, 150, 200], [1, 0.8, 0.3] );});
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦The FlashList + single Shared Value pattern that cut a wallpaper grid's initial render from 820ms to 310ms and memory from 180MB to 96MB
✦Six production pitfalls found through Crashlytics and on-device profiling: delayed runOnJS, useDerivedValue caching, missing cancelAnimation, and more
✦The two withSpring presets I settled on across 50M cumulative downloads, plus an on-device checklist for ScrollView vs. gesture conflicts
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
React Native Gesture Handler 2 introduced a declarative API using Gesture objects. This approach is more flexible and readable than the older component-based API (PanGestureHandler, etc.).
Gesture Composition — Simultaneous Recognition and Exclusive Control
When combining multiple gestures, use Gesture.Simultaneous(), Gesture.Exclusive(), and Gesture.Race() depending on your needs.
// Recognize pinch + rotation simultaneouslyconst pinchGesture = Gesture.Pinch() .onUpdate((event) => { scale.value = savedScale.value * event.scale; });const rotationGesture = Gesture.Rotation() .onUpdate((event) => { rotation.value = savedRotation.value + event.rotation; });// Simultaneous: both gestures work at the same timeconst composed = Gesture.Simultaneous(pinchGesture, rotationGesture);// Exclusive: only the first recognized gesture wins// const exclusive = Gesture.Exclusive(longPressGesture, tapGesture);// Race: the first gesture to meet its activation condition wins// const race = Gesture.Race(swipeGesture, panGesture);
Pattern 1 — Tinder-Style Card Swipe UI
The swipeable card stack is a staple of matching apps and content discovery screens. Cards can be swiped left or right, with different actions (Like / Dislike) triggered based on direction.
Pattern 2 — iOS-Style Bottom Sheet with Snap Points
This pattern recreates the bottom sheet from Apple Maps, with three snap points (collapsed, half-screen, full-screen) and velocity-based snap targeting.
import React from 'react';import { StyleSheet, Dimensions, View } from 'react-native';import { Gesture, GestureDetector } from 'react-native-gesture-handler';import Animated, { useSharedValue, useAnimatedStyle, withSpring, clamp,} from 'react-native-reanimated';const { height: SCREEN_HEIGHT } = Dimensions.get('window');// Snap point definitions (distance from top of screen)const SNAP_POINTS = { collapsed: SCREEN_HEIGHT - 120, // Collapsed: only handle visible half: SCREEN_HEIGHT * 0.5, // Half: covers half the screen expanded: 60, // Full: just below status bar};const VELOCITY_THRESHOLD = 500; // px/s — snaps to next point above this speedconst BottomSheet: React.FC<{ children: React.ReactNode }> = ({ children }) => { const translateY = useSharedValue(SNAP_POINTS.collapsed); const prevTranslateY = useSharedValue(SNAP_POINTS.collapsed); const snapToNearest = (currentY: number, velocityY: number) => { 'worklet'; const points = [SNAP_POINTS.expanded, SNAP_POINTS.half, SNAP_POINTS.collapsed]; // High velocity: snap based on direction if (Math.abs(velocityY) > VELOCITY_THRESHOLD) { if (velocityY < 0) { // Fast upward swipe — find nearest point above const upper = points.filter(p => p < currentY); return upper.length > 0 ? upper[upper.length - 1] : points[0]; } else { // Fast downward swipe — find nearest point below const lower = points.filter(p => p > currentY); return lower.length > 0 ? lower[0] : points[points.length - 1]; } } // Low velocity: snap to nearest point let closest = points[0]; let minDist = Math.abs(currentY - points[0]); for (const point of points) { const dist = Math.abs(currentY - point); if (dist < minDist) { minDist = dist; closest = point; } } return closest; }; const panGesture = Gesture.Pan() .onStart(() => { prevTranslateY.value = translateY.value; }) .onUpdate((event) => { // Clamp within vertical bounds translateY.value = clamp( prevTranslateY.value + event.translationY, SNAP_POINTS.expanded - 20, // Allow slight overscroll up SNAP_POINTS.collapsed + 20 // Allow slight overscroll down ); }) .onEnd((event) => { const target = snapToNearest(translateY.value, event.velocityY); translateY.value = withSpring(target, { damping: 25, stiffness: 300, mass: 0.8, velocity: event.velocityY, }); }); const sheetStyle = useAnimatedStyle(() => ({ transform: [{ translateY: translateY.value }], })); return ( <GestureDetector gesture={panGesture}> <Animated.View style={[styles.sheet, sheetStyle]}> <View style={styles.handle} /> {children} </Animated.View> </GestureDetector> );};// Expected behavior:// - Bottom sheet is draggable up and down// - On release, springs to the nearest snap point// - Fast swipes jump to the next snap point in the swipe direction
Pattern 3 — Pinch-to-Zoom Image Viewer with Pan
A full-featured image viewer like Instagram or Photos, combining pinch-to-zoom, pan gestures, double-tap zoom, and automatic reset when zooming out.
Pattern 4 — Shared Element Transitions (List → Detail)
When navigating from a list to a detail screen, the tapped element seamlessly scales and repositions into its new layout. This uses Reanimated's Layout transitions and SharedTransition.
import Animated, { FadeIn, FadeOut, LinearTransition, SharedTransition, withSpring,} from 'react-native-reanimated';// Custom shared transition configurationconst customTransition = SharedTransition.custom((values) => { 'worklet'; return { // Define animation for each property width: withSpring(values.targetWidth, { damping: 20, stiffness: 200 }), height: withSpring(values.targetHeight, { damping: 20, stiffness: 200 }), originX: withSpring(values.targetOriginX, { damping: 20, stiffness: 200 }), originY: withSpring(values.targetOriginY, { damping: 20, stiffness: 200 }), borderRadius: withSpring( values.targetWidth > values.currentWidth ? 0 : 12, { damping: 20 } ), };});// List screen card componentconst ListCard: React.FC<{ item: { id: string; imageUri: string; title: string }; onPress: () => void;}> = ({ item, onPress }) => { return ( <Animated.View entering={FadeIn.duration(300)} layout={LinearTransition.springify().damping(20)} > <Pressable onPress={onPress}> {/* sharedTransitionTag links source and destination */} <Animated.Image source={{ uri: item.imageUri }} style={styles.listImage} sharedTransitionTag={`image-${item.id}`} sharedTransitionStyle={customTransition} /> <Animated.Text sharedTransitionTag={`title-${item.id}`} style={styles.listTitle} > {item.title} </Animated.Text> </Pressable> </Animated.View> );};// Detail screen header componentconst DetailHeader: React.FC<{ item: { id: string; imageUri: string; title: string };}> = ({ item }) => { return ( <Animated.View entering={FadeIn.delay(200)}> {/* Same sharedTransitionTag as the list item */} <Animated.Image source={{ uri: item.imageUri }} style={styles.detailImage} sharedTransitionTag={`image-${item.id}`} sharedTransitionStyle={customTransition} /> <Animated.Text sharedTransitionTag={`title-${item.id}`} style={styles.detailTitle} > {item.title} </Animated.Text> </Animated.View> );};// Expected behavior:// - Tapping a list card causes its image and title to spring-animate// into their detail screen positions and sizes// - Going back plays the transition in reverse// - Other elements fade in/out naturally
Performance Optimization — Keeping a Solid 60fps
1. Memoize useAnimatedStyle Computations
Heavy calculations inside useAnimatedStyle run every frame on the UI thread. Pre-compute with useDerivedValue and keep useAnimatedStyle lean.
Setting Up Reanimated & Gesture Handler in a Rork Project
Here's how to integrate both libraries into your Rork project.
# Run from your Rork project root# 1. Install packagesnpx expo install react-native-reanimated react-native-gesture-handler# 2. Add the Reanimated plugin to babel.config.js# ⚠️ The reanimated/plugin MUST be the last item in the plugins array
// babel.config.jsmodule.exports = function (api) { api.cache(true); return { presets: ['babel-preset-expo'], plugins: [ // Other plugins (react-native-dotenv, etc.) go here 'react-native-reanimated/plugin', // Must be last ], };};
// app/_layout.tsx (when using Expo Router)import { GestureHandlerRootView } from 'react-native-gesture-handler';export default function RootLayout() { return ( <GestureHandlerRootView style={{ flex: 1 }}> {/* Your existing layout */} <Stack /> </GestureHandlerRootView> );}// Place GestureHandlerRootView at the root of your app — only once// Nesting it causes unexpected gesture recognition issues
Things the docs won't tell you — lessons from production
Everything above is the "write it correctly and it works" story. What follows is the opposite: code I wrote correctly that still broke in production. These are six pitfalls I hit running my own apps, all pulled from on-device profiling and Crashlytics logs on wallpaper and calming-tone titles.
1. runOnJS "fires all at once at the end" when the JS thread is busy
If you call runOnJS to push state or events on gesture end, those calls queue up whenever the JS thread is busy and then fire in a burst after the animation finishes. My swipe-progress telemetry lagged by roughly 140ms in feel, and during rapid swipes a "like" would land on the previous card.
The fix was to throttle inside the worklet — reducing the number of JS round-trips itself.
import { useSharedValue, runOnJS } from 'react-native-reanimated';import { Gesture } from 'react-native-gesture-handler';// ❌ runOnJS on every update (lag accumulates when the JS thread stalls)const badPan = Gesture.Pan().onUpdate((e) => { 'worklet'; translateX.value = e.translationX; runOnJS(reportSwipeProgress)(e.translationX); // a JS round-trip per frame});// ✅ Throttle to 100ms inside the worklet; commit only onceconst lastSent = useSharedValue(0);const goodPan = Gesture.Pan() .onUpdate((e) => { 'worklet'; translateX.value = e.translationX; const now = Date.now(); if (now - lastSent.value > 100) { lastSent.value = now; runOnJS(reportSwipeProgress)(e.translationX); } }) .onEnd((e) => { 'worklet'; runOnJS(commitSwipe)(e.translationX); // commit just once });
After this, the perceived event lag during rapid swipes dropped to zero. Treat runOnJS as something you call at milestones, not every frame, and a whole class of bugs disappears.
2. Heavy math inside useAnimatedStyle drops frames
The body of useAnimatedStyle is re-evaluated every frame on the UI thread. Stack several interpolations or trig calls in there and mid-range devices drop frames. On a physical Pixel 4a, a card interpolating rotation, shadow, and opacity at once fell from 60fps to 44fps.
Move derived values into useDerivedValue to cache them, and keep useAnimatedStyle to "just apply the values."
After the split, the Pixel 4a held 60fps. Whenever I notice "we're computing something here" inside useAnimatedStyle, the first question I ask is whether it can move into useDerivedValue.
3. A Shared Value per list cell makes the initial render slow
On a screen that lays out hundreds of cells — like a wallpaper grid — giving each cell its own useSharedValue and gesture makes the initial mount visibly heavy. A grid of about 100 wallpapers took 820ms to render initially and ballooned to 180MB.
Virtualize cells with FlashList and collapse the animation onto a single Shared Value that only the actively touched cell reads. That brought it down to a 310ms initial render and 96MB.
import { FlashList } from '@shopify/flash-list';import { useSharedValue } from 'react-native-reanimated';// ✅ Share only the active cell's index and progress (not per-cell)const activeIndex = useSharedValue(-1);const dragProgress = useSharedValue(0);<FlashList data={wallpapers} estimatedItemSize={180} numColumns={3} renderItem={({ item, index }) => ( <WallpaperCell item={item} index={index} activeIndex={activeIndex} dragProgress={dragProgress} /> )}/>
You almost never need "every cell can animate." If the one being touched is smooth, the experience feels complete.
4. Not calling cancelAnimation on unmount leaves crashes in your logs
If a component with a running animation unmounts mid-transition, a worklet can reach for an already-freed Shared Value and crash. I had about 23 unexplained Reanimated-originated crashes a month piling up in Crashlytics with no clear repro steps.
Calling cancelAnimation in a useEffect cleanup dropped that to one a month the following month.
import { cancelAnimation } from 'react-native-reanimated';import { useEffect } from 'react';useEffect(() => { return () => { // Stop running animations on unmount cancelAnimation(translateX); cancelAnimation(scale); };}, []);
It's unglamorous, but crash-free rate maps directly to your store rating. On any screen with animation, I now add this mechanically as a template.
5. withSpring defaults are "too fast" — keep two presets only
Leaving withSpring on its defaults makes the feel drift subtly across the app. I eventually settled on just two presets: a calm one for screen transitions and sheets, and a snappy one for toggles and small feedback.
// The only two presets shared across the appexport const SPRING = { // Screen transitions, bottom sheets (relaxed) calm: { damping: 18, stiffness: 120, mass: 1 }, // Toggles, small feedback (snappy) snappy: { damping: 14, stiffness: 220, mass: 0.8 },} as const;// UsagetranslateY.value = withSpring(0, SPRING.calm);scale.value = withSpring(1, SPRING.snappy);
Once I narrowed it to two, the app's overall feel lined up, and reviews mentioning "the motion feels nice" became more frequent. Reducing choices stabilizes the experience more than adding them.
6. ScrollView vs. gesture conflicts only show up on a device
Put a horizontal swipe inside a vertical scroll and it behaves in the simulator but conflicts under a real thumb. A missing simultaneousWithExternalGesture or blocksExternalGesture meant cards moved when users tried to scroll — something I only learned from a review.
Here's the list I always verify on a device before release.
Move a horizontal swipe inside a vertical scroll in every direction, using only your thumb on a real device
Does the ScrollView follow immediately after a swipe commits?
Open a bottom sheet partway, release, and confirm the snap engages
Can you transition from pinch into pan (choosing between Gesture.Race / Gesture.Simultaneous)?
With the device in low-power mode, does it still hold 60fps?
import { Gesture } from 'react-native-gesture-handler';// ✅ Coexist with the parent's vertical scrollconst pan = Gesture.Pan() .activeOffsetX([-15, 15]) // fire only after 15px of horizontal movement .failOffsetY([-10, 10]); // give up the horizontal swipe after 10px vertical
The activeOffsetX / failOffsetY combination makes no difference in the simulator. Whether those two lines are present is the single most important item on my on-device test checklist.
A single next step
Thank you for reading this far. You don't need to adopt all of this at once. If something in your app's animations feels "vaguely heavy" today, start by looking inside useAnimatedStyle and moving any interpolation or math out into useDerivedValue. That one step changes the feel of most hitches.
From there, add the cancelAnimation cleanup mechanically to every screen that animates, and your crash-free rate — quiet but consequential — will improve on its own. I'm still migrating my own wallpaper-app screens to this approach one at a time. If you're growing an app solo too, I hope a few of these earn you an easy step tomorrow.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.