Gestures and animations
Wire React Native Gesture Handler, Reanimated, @gorhom/bottom-sheet and Keyboard Controller into an app whose screens are native: where each provider goes, what they do next to the transitions and gestures of the platform, and what to test.
Written against react-native-gesture-handler 3.3, react-native-reanimated 4.7 with react-native-worklets 0.13, @gorhom/bottom-sheet 5.2 and react-native-keyboard-controller 1.22. Reanimated 4.7 needs React Native 0.86 or later; on 0.85 use Reanimated 4.6 with react-native-worklets 0.12.
A card the user swipes away, a list that slides in, a bottom sheet of your own, a form that rises with the keyboard: these come from four libraries most React Native apps have. Each of them asks you to wrap "the root of the app". Here the screens are the platform's, and each one has React content of its own, so this guide shows where each wrapper goes and why. You end with:
- gestures that work on every screen, next to the swipe back;
- animations that start with the transition, or after it;
- the sheets of @gorhom/bottom-sheet, and when to use the platform's sheet instead;
- a keyboard provider that reaches every screen.
What you need
- An app with
createNavigation, and the screens of Building a screen. - One tree: how screens render, and the difference between
appProviderandscreenProvider. This guide uses both. - Transitions: the moves the platform makes between screens.
How it fits
The native side moves the screens: a push, the swipe back, a sheet are UIKit's and Android's own, not JavaScript animations. React draws what is inside each screen. So:
- Gestures and animations inside a screen are yours, with Gesture Handler and Reanimated, as in any React Native app.
- Moves between screens are the navigation's: presets, shared elements and the zoom.
- Each screen's content is a React root of its own by default. With
appProviderthe screens render in one hidden React tree, and the native side moves each screen's views into its native screen.
Point 3 decides where a wrapper goes. A provider that only holds React context reaches the screens from appProvider. A wrapper that is a native view the screen's touches must pass through, or a place where it draws, has to be inside each screen: screenProvider. The steps below go through each library with this rule.
1. Gesture Handler in every screen
Gesture Handler needs a GestureHandlerRootView above every view with a gesture, for two reasons:
GestureDetectorchecks for it in React context, and in development throws "GestureDetector must be used as a descendant of GestureHandlerRootView" without it.- Android The root view is a native view that sends the touches inside it to Gesture Handler. A gesture on a view that isn't inside one never sees a touch.
Each screen is a root of its own, so a root view at the top of one screen doesn't reach the others. screenProvider wraps every screen:
import type { ReactNode } from 'react';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
export function ScreenProvider({ children }: { children: ReactNode }) {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
{children}
</GestureHandlerRootView>
);
}
import { createNavigation } from '@apexnative/navigation';
import { ScreenProvider } from './src/ScreenProvider';
export const Navigation = createNavigation({
screens: { Home: HomeScreen, Details: DetailsScreen },
root: 'Home',
screenProvider: ScreenProvider,
});
Every screen now has its own root view, and a gesture works on any of them. Modals, sheets and overlays are screens too, so they get it as well. style={{ flex: 1 }} lets the root view fill the screen; without a style it does the same by default, but a style of your own replaces that default.
Gesture Handler 2 had gestureHandlerRootHOC to wrap each screen of a native navigation; version 3 has no such function. screenProvider does the same for every screen, with any version.
In one tree
With appProvider all screens render in one React tree, so a GestureHandlerRootView in appProvider looks like it is above every screen. Put it in screenProvider anyway:
export const Navigation = createNavigation({
screens: { Home: HomeScreen, Details: DetailsScreen },
root: 'Home',
appProvider: AppProvider, // your query client, store, theme
screenProvider: ScreenProvider, // the root view of step 1
});
In one tree the native side takes each screen's views out of the hidden tree and puts them into the native screen. A root view in appProvider stays behind in the hidden tree: the React context still reaches the screens, so GestureDetector doesn't complain, but Android the screen's touches never pass through that root view, and its gestures don't start. A root view in screenProvider moves with the screen's views, and finds the screen as its React Native root.
iOS Gestures start without the root view's help: each one is a UIKit gesture recognizer on its own view. When a gesture starts, Gesture Handler also cancels the touch of React Native's own touchables, such as a Pressable, under it: it finds that touch on the root of the screen's React content, which in one tree is a React Native surface view too. A drag across a Pressable inside a pan doesn't fire the press, in both modes.
A first gesture
A card the user drags sideways, and that springs back when let go. Gesture Handler 3 runs the callbacks on the UI thread with Reanimated:
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
} from 'react-native-reanimated';
import {
GestureDetector,
usePanGesture,
} from 'react-native-gesture-handler';
export function Card() {
const x = useSharedValue(0);
const pan = usePanGesture({
activeOffsetX: [-10, 10],
onUpdate: (event) => {
x.value = event.translationX;
},
onDeactivate: () => {
x.value = withSpring(0);
},
});
const style = useAnimatedStyle(() => ({
transform: [{ translateX: x.value }],
}));
return (
<GestureDetector gesture={pan}>
<Animated.View style={[{ height: 120 }, style]} />
</GestureDetector>
);
}
activeOffsetX starts the pan only after a sideways move of 10 points, so a vertical scroll of the screen around the card stays a scroll.
2. Gestures and the swipe back
iOS The swipe back goes from the left edge, and on iOS 26 from anywhere on the content too. A sideways gesture of your own, such as the card above, a carousel or a slider, competes with the swipe from the content. popGesture of the screen's options narrows the swipe back:
import { defineScreen } from '@apexnative/navigation';
export const HomeScreen = defineScreen({
component: Home,
options: (s) => s.popGesture('edge'),
});
popGesture | The swipe back |
|---|---|
true | From the edge and from the content: the default. |
'edge' | From the edge only: a sideways drag on the content goes to your gesture. |
false | Off. The back button still works. |
'edge' is the right choice for a screen with sideways gestures: the user still goes back from the edge. false is for a screen where a swipe from the edge is the content's own, such as a drawing canvas. A screen can change it while it shows, such as only while the user draws:
navigation.setOptions({ popGesture: false });
Android The system back swipes from either edge of the screen, and the app doesn't change it. Keep sideways gestures away from the very edges, or let the user start them from the middle.
The ways back and usePreventLeave, which holds every one of them, are on Back and gestures.
3. Gestures in a sheet
A screen shown as the platform's sheet (showModal with sheet) moves with a vertical drag. A vertical gesture of yours inside it competes with that drag:
- A sideways gesture doesn't compete: the card of step 1 works in a sheet as it is.
- A list in a sheet:
expandsOnScroll: falsegives the drag to the list, and only the top of the sheet moves it. See Put a list in it. - A vertical pan of your own, such as a slider that moves up and down: Android with
expandsOnScroll: falsethe sheet takes only drags that start in its top 48 dp, and the rest go to the content. Test it on both platforms, at every detent of the sheet.
4. Reanimated
Reanimated has no provider, so a screen's content uses it with no setup of the navigation's, in a root of its own and in one tree alike. What it needs is the Babel plugin of react-native-worklets, last in the list:
module.exports = {
presets: ['module:@react-native/babel-preset'],
plugins: ['react-native-worklets/plugin'],
};
Animations as the screen comes in
An entering animation runs when its view mounts. A pushed screen mounts its content as the push starts, so the two run together: iOS the screen slides in while its rows fade in, and Android in one tree the push starts once the content has rendered, so again both start together (see Waiting for the content).
import Animated, { FadeInDown } from 'react-native-reanimated';
function Row({ title, index }: { title: string; index: number }) {
return (
<Animated.Text entering={FadeInDown.delay(index * 50)}>
{title}
</Animated.Text>
);
}
Short and small animations look right during the push. For one the user should see from its start, such as a chart that grows, start it once the screen is in place. useScreenAppear runs after the transition has ended:
import Animated, {
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
import { useScreenAppear } from '@apexnative/navigation';
function Chart() {
const progress = useSharedValue(0);
useScreenAppear(() => {
progress.value = withTiming(1, { duration: 600 });
});
const style = useAnimatedStyle(() => ({
transform: [{ scaleY: progress.value }],
}));
return <Animated.View style={[{ height: 200 }, style]} />;
}
The chart stays flat while Details slides in, and grows once it is in place. useScreenAppear runs each time the screen appears, also when the user comes back to it from a screen over it; to grow the chart only the first time, keep a ref and return early after the first call. More on the moment each event comes: What a screen goes through.
Animations as the screen goes
The way out is native too: the back button or the swipe moves the screen out, and its React content unmounts after. So an exiting animation on the screen's views runs when nothing of the screen shows anymore. Use exiting for views that leave while the screen stays, such as a row removed from a list.
Shared elements
Reanimated has shared element transitions (sharedTransitionTag), behind a feature flag. They are built for the screens of react-native-screens, which Reanimated finds in the React tree; the screens of this navigation are not those, so a sharedTransitionTag doesn't move a view from one screen into the next here.
Use the navigation's own: a nativeID on each view, and transition: { shared: [...] } on the push. They work on both platforms, follow the swipe back, and need no feature flag. On iOS 18+ the zoom opens a screen out of a view as the system apps do. Both are on Transitions out of a view.
5. @gorhom/bottom-sheet
BottomSheet and BottomSheetModal are drawn with Gesture Handler and Reanimated, so they need step 1. BottomSheetModal also needs a BottomSheetModalProvider above it, and it goes in screenProvider, inside the root view, in both modes:
import type { ReactNode } from 'react';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { BottomSheetModalProvider } from '@gorhom/bottom-sheet';
export function ScreenProvider({ children }: { children: ReactNode }) {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
</GestureHandlerRootView>
);
}
BottomSheetModalProvider draws its sheets in a host it renders after its children, where the provider is. In screenProvider that host is in the screen, so the sheet shows over the screen's content. In appProvider the host would be in the hidden tree of one tree, outside every screen, and a presented sheet wouldn't show.
A screen presents one as the library's docs show, with a ref:
import { useRef } from 'react';
import { Button, Text } from 'react-native';
import {
BottomSheetModal,
BottomSheetView,
} from '@gorhom/bottom-sheet';
function Home() {
const sheet = useRef<BottomSheetModal>(null);
return (
<>
<Button
title="Filters"
onPress={() => sheet.current?.present()}
/>
<BottomSheetModal ref={sheet}>
<BottomSheetView>
<Text>Filters</Text>
</BottomSheetView>
</BottomSheetModal>
</>
);
}
The sheet rises over Home's content. It is part of the screen, so it stays inside the screen: it can't cover the top bar or the tab bar, which are native views around the screen's content, and it goes away with the screen when the user goes back.
The platform's sheet or this one
showModal with sheet shows a screen in UIKit's sheet on iOS and the Material bottom sheet on Android:
navigation.showModal('Filters', {
stack: false,
sheet: { detents: ['medium', 'large'], grabber: true },
});
| You want | Pick |
|---|---|
| A sheet over everything, the bars too, that the user drags down to close | The platform's sheet |
| The look and the detents of the system, with no code of your own | The platform's sheet |
| A sheet that is a screen: its own options, events, a stack inside | The platform's sheet |
| A sheet that stays inside the screen, such as a panel under a map | @gorhom/bottom-sheet's BottomSheet |
| The same look of your own on both platforms, with custom handles and backdrops | @gorhom/bottom-sheet |
The platform's sheet is walked through in Bottom sheets: detents, a map kept usable under it, a list inside it, the detent the user picked.
6. Keyboard Controller
KeyboardProvider gives its hooks and components, such as useKeyboardHandler and KeyboardAwareScrollView, the keyboard's height as it moves. Its native view follows the keyboard of the whole window, not the touches of a screen, so where it goes depends on the mode:
-
In one tree, put it in
appProvider, once: every screen reads the same provider.src/AppProvider.tsximport type { ReactNode } from 'react';import { KeyboardProvider } from 'react-native-keyboard-controller';export function AppProvider({ children }: { children: ReactNode }) {return <KeyboardProvider>{children}</KeyboardProvider>;}Its native view is in the hidden tree; it listens to the keyboard of the app's window from there. Test a form with
KeyboardAwareScrollViewon both platforms, and the interactive dismiss of the keyboard on iOS. -
With a root per screen, put it in
screenProvider, next to the root view of step 1. Each screen then has a provider of its own, and each one follows the keyboard while its screen is mounted, also the screens under the top one. A listener ofKeyboardEventsoutside the screens can hear the same keyboard event once for each of them; listen inside the screen that needs it, with the library's hooks.
A library asks you to wrap the app root
The same questions place any library that says "wrap your app in <SomeProvider>":
- It holds state in React context, such as a store, a query client, a theme, a language. With one tree,
appProvider: every screen shares it. Without,screenProvider, and each screen has a copy of its own; state the screens share then lives in a module, as A root per screen shows. - It is a native view that touches pass through, such as
GestureHandlerRootView.screenProvider, in both modes: inappProviderthe view stays in the hidden tree, away from the screens' touches. - It draws in a host of its own: a portal, a toast, a modal of its own (
BottomSheetModalProvider, a portal provider).screenProvider, in both modes: inappProviderthe host is in the hidden tree, and what it draws doesn't show. What must show above the bars and every screen, such as a toast, belongs in an overlay instead. - It listens to the window or the app, such as the keyboard, the orientation, the network. Once in
appProviderwith one tree; inscreenProviderwithout, knowing that each mounted screen has an instance. Test it. - Its hooks need its context. A hook works only in a screen inside its provider.
screenProviderrenders inside the screen, so the screen's own hooks, such asuseNavigation, work in it too;appProvideris above every screen, and has none.
When unsure, screenProvider is the safe place: everything in it is inside the screen, in both modes.
The whole setup
The two providers of an app with one tree and all four libraries:
import type { ReactNode } from 'react';
import { KeyboardProvider } from 'react-native-keyboard-controller';
export function AppProvider({ children }: { children: ReactNode }) {
return <KeyboardProvider>{children}</KeyboardProvider>;
}
import type { ReactNode } from 'react';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { BottomSheetModalProvider } from '@gorhom/bottom-sheet';
export function ScreenProvider({ children }: { children: ReactNode }) {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
</GestureHandlerRootView>
);
}
import { createNavigation } from '@apexnative/navigation';
import { AppProvider } from './src/AppProvider';
import { ScreenProvider } from './src/ScreenProvider';
export const Navigation = createNavigation({
screens: { Home: HomeScreen, Details: DetailsScreen },
root: 'Home',
appProvider: AppProvider,
screenProvider: ScreenProvider,
});
Without one tree, leave out appProvider and move KeyboardProvider into ScreenProvider, around the BottomSheetModalProvider.
What's next
- One tree: the two modes, and which to pick.
- Transitions: presets, shared elements, the zoom, animations of your own.
- Bottom sheets: the platform's sheet, step by step.
- Back and gestures: every way back, and how to hold it.
- The reference:
appProvider,screenProvider,useScreenAppear.