First: do you need the library?
| Option | Snap points | Scrollable content | Native sheet | Use it for |
|---|---|---|---|---|
| @gorhom/bottom-sheet | Any number of snap points | Built-in scroll views | No — drawn in JS/Reanimated | Maps, filters, players, anything with a half-open state |
| Expo Router modal presentation | iOS detents only | Normal ScrollView | Yes — real system sheet | A screen that just needs to slide up |
| React Native Modal | None | Normal ScrollView | Partly | Confirm dialogs, not sheets |
If the sheet is really just a screen that slides up and gets dismissed, an Expo Router modal route is less code and gives you a genuine platform sheet. The moment you need a half-open state — a map with a peeking result list, a player with a mini bar — you need real snap points, and that means @gorhom/bottom-sheet.
Step 1 — Install all three packages
npx expo install @gorhom/bottom-sheet \
react-native-reanimated react-native-gesture-handlerThe sheet is not a standalone component — it’s a Reanimated animation driven by a Gesture Handler pan. Installing only the sheet package produces a module-not-found error at best and a component that renders but won’t drag at worst. Use npx expo install rather than npm install so the native versions match your SDK.
Step 2 — Two providers at the root
This is the step people skip, and it’s the reason for most “the sheet does nothing” bug reports. In an Expo Router app, put both in the root layout:
// app/_layout.tsx
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { BottomSheetModalProvider } from '@gorhom/bottom-sheet';
import { Stack } from 'expo-router';
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<BottomSheetModalProvider>
<Stack />
</BottomSheetModalProvider>
</GestureHandlerRootView>
);
}style={{ flex: 1 }} on the root view is not optional. Without it the view collapses to zero height and everything inside — including your entire app — disappears, which is a memorable five minutes of debugging.
Step 3 — A complete sheet
import { useCallback, useMemo, useRef } from 'react';
import { Button, StyleSheet, Text, View } from 'react-native';
import BottomSheet, {
BottomSheetModal, BottomSheetBackdrop, BottomSheetScrollView,
} from '@gorhom/bottom-sheet';
export default function PlaceScreen({ place }) {
const sheetRef = useRef<BottomSheetModal>(null);
// memoize: a new array each render re-computes the snap layout
const snapPoints = useMemo(() => ['25%', '60%', '90%'], []);
const renderBackdrop = useCallback(
(props) => (
<BottomSheetBackdrop
{...props}
appearsOnIndex={0}
disappearsOnIndex={-1}
pressBehavior="close"
/>
),
[],
);
return (
<View style={{ flex: 1 }}>
<Button title="Show details" onPress={() => sheetRef.current?.present()} />
<BottomSheetModal
ref={sheetRef}
index={1} // open at 60%
snapPoints={snapPoints}
enablePanDownToClose
backdropComponent={renderBackdrop}
backgroundStyle={styles.sheetBackground}
handleIndicatorStyle={styles.handle}
keyboardBehavior="interactive"
keyboardBlurBehavior="restore"
onChange={(i) => { if (i === -1) console.log('closed'); }}
>
<BottomSheetScrollView contentContainerStyle={styles.content}>
<Text style={styles.title}>{place.name}</Text>
<Text style={styles.body}>{place.description}</Text>
</BottomSheetScrollView>
</BottomSheetModal>
</View>
);
}
const styles = StyleSheet.create({
sheetBackground: { backgroundColor: '#16161a' },
handle: { backgroundColor: '#666', width: 44 },
content: { padding: 20, paddingBottom: 48, gap: 12 },
title: { color: '#fff', fontSize: 22, fontWeight: '700' },
body: { color: 'rgba(255,255,255,0.7)', fontSize: 15, lineHeight: 22 },
});Note BottomSheetScrollView, not the normal one. The sheet needs to know your scroll position so it can decide whether a downward drag scrolls the content or closes the sheet. A plain ScrollView swallows the gesture and the sheet stops responding to drags entirely — the second-most-reported problem after the missing root view.
The traps, in the order you’ll hit them
- Nothing drags. Missing
GestureHandlerRootView, or it’s not at the true root. - “
present()is not a function”. You’re usingBottomSheetModalwithoutBottomSheetModalProvider, or you calledpresent()on a plainBottomSheet, which usessnapToIndex()instead. - The sheet is there but invisible. No
backgroundStyle, so it’s the default white on a white screen — or in dark mode, dark on dark. Always set it explicitly. - Content is cut off at the bottom.The sheet doesn’t apply the home-indicator inset for you. Add bottom padding from
useSafeAreaInsets(), or a fixedpaddingBottomas above. - The keyboard covers the input. Use
BottomSheetTextInputand the twokeyboard*props. See the form validation guide for the rest of the keyboard story. - The sheet re-snaps oddly on re-render. An inline
snapPoints={['25%', '60%']}array is a new array every render. Memoize it.
If your content height varies, skip percentages entirely: pass enableDynamicSizingand let the sheet measure the content. It saves you hard-coding a snap point that’s wrong on a small screen.
The shortcut: generate the wiring, keep the taste
Everything above is setup — two providers, three packages, six props with specific names. None of it is a product decision. The product decisions are where the snap points sit and what’s visible at each one, and those you can only judge with a thumb on a real screen.
Describe the interaction in ShipNative — “a map of nearby cafés where tapping a pin opens a draggable detail sheet with photos and hours” — and it wires the providers, the sheet, the backdrop and the scroll view, then runs it on your phone so you can drag it. Adjust by prompting; export the full Expo project when you’re happy.
Build it free
Describe your app in one sentence and have it running on your phone in minutes at shipnative.dev. No credit card, full React Native source export whenever you want it.