React Native Bottom Sheet Tutorial: @gorhom/bottom-sheet with Reanimated in Expo (2026)

A complete 2026 tutorial on building production-grade bottom sheets in React Native with @gorhom/bottom-sheet and Reanimated. Snap points, modals, scrollable content, keyboard handling, accessibility, and a full filter-sheet example for Expo apps.

Bottom sheets are everywhere in modern mobile UIs — from Apple Maps' route picker, to Spotify's "now playing" drawer, to the share sheet on every iPhone. They give users a focused, dismissible surface without yanking them off the current screen, and when implemented well they feel inseparable from the platform itself. In React Native, the de facto solution is @gorhom/bottom-sheet, a community library that has quietly become the standard because it wraps react-native-reanimated and react-native-gesture-handler into a single, performant, declarative API.

So, this tutorial walks through everything you need to ship a production-quality bottom sheet in an Expo app in 2026: installation, snap points, modals, scrollable content, keyboard handling, programmatic control, theming, accessibility, and the small handful of footguns that catch almost every team on their first integration. (I've personally been bitten by at least three of them, which is why I've been a little obsessive about writing them down.)

What is a bottom sheet, and when should you use one?

A bottom sheet is a panel that slides up from the bottom of the screen and can be dragged to different heights ("snap points") or dismissed with a downward swipe. The Material Design and Apple Human Interface guidelines both formalize the pattern, and users have come to expect it to behave the same way across apps: drag-to-resize, tap-outside-to-dismiss, and a visible drag handle.

Use a bottom sheet when you need to:

  • Surface secondary actions (share, edit, delete) without a full screen transition
  • Show contextual detail about a list item, map pin, or media element
  • Collect input that should be dismissible — filters, comments, quick forms
  • Display a persistent panel that the user can resize (a "now playing" card, a chat thread)

Avoid a bottom sheet when the user must complete the task before continuing. That's what a full-screen modal or a new route is for. Sheets are interruptions you can dismiss; if dismissing it would lose data the user cares about, it's the wrong primitive.

Why @gorhom/bottom-sheet over the alternatives?

Honestly, you have three realistic options in 2026:

  • React Native's built-in Modal — works, but it's a binary open/closed component with no drag-to-resize, no snap points, no smooth gesture-driven animation, and no built-in backdrop. You can stack a Modal on top of a PanResponder and a Reanimated shared value to fake it, but you'll basically end up reinventing the library — badly.
  • The native ActionSheetIOS (and @expo/react-native-action-sheet) — perfect for a short list of platform-styled actions, but it's not a general-purpose container. It doesn't accept arbitrary children.
  • @gorhom/bottom-sheet — declarative, JS-driven on the UI thread via Reanimated worklets, supports snap points, dynamic sizing, scrollable content (FlatList/ScrollView/SectionList wrappers), keyboard handling, backdrops, and programmatic control. It works in Expo Go for basic cases and in development builds for everything else.

For anything beyond a simple action picker, reach for @gorhom/bottom-sheet. It's MIT-licensed, actively maintained, and the version 5 line ships with first-class support for Reanimated 4 and the React Native New Architecture (Fabric).

Installing @gorhom/bottom-sheet in an Expo project

The library has three peer dependencies: react-native-reanimated, react-native-gesture-handler, and React Native itself. In a fresh Expo SDK 55 project these are either preinstalled (Reanimated) or one command away.

npx create-expo-app@latest my-app
cd my-app
npx expo install @gorhom/bottom-sheet react-native-reanimated react-native-gesture-handler

If you're adding the library to an existing app, make sure Reanimated's Babel plugin is the last entry in babel.config.js:

module.exports = function (api) {
  api.cache(true);
  return {
    presets: ['babel-preset-expo'],
    plugins: ['react-native-reanimated/plugin'],
  };
};

Now, wrap your app's root in GestureHandlerRootView. Without it, gestures inside the sheet will silently no-op on Android — not throw, just quietly do nothing, which is the worst kind of bug to debug. If you're using Expo Router, do this in 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>
  );
}

The BottomSheetModalProvider is only required if you plan to use BottomSheetModal (the imperatively-controlled variant). For a simple inline BottomSheet you can skip it — but most apps end up needing it eventually, so adding it once at the root saves rework later.

Your first bottom sheet

The simplest possible sheet is a BottomSheet rendered alongside your screen content. It manages its own open/closed state through snap points: when the active index is -1 the sheet is closed, otherwise it sits at the snap point at that index.

import React, { useRef, useMemo, useCallback } from 'react';
import { View, Text, Button, StyleSheet } from 'react-native';
import BottomSheet, { BottomSheetView } from '@gorhom/bottom-sheet';

export default function HomeScreen() {
  const sheetRef = useRef<BottomSheet>(null);
  const snapPoints = useMemo(() => ['25%', '50%', '90%'], []);

  const open = useCallback(() => sheetRef.current?.snapToIndex(1), []);
  const close = useCallback(() => sheetRef.current?.close(), []);

  return (
    <View style={styles.container}>
      <Button title="Open sheet" onPress={open} />

      <BottomSheet
        ref={sheetRef}
        index={-1}
        snapPoints={snapPoints}
        enablePanDownToClose
      >
        <BottomSheetView style={styles.content}>
          <Text style={styles.title}>Hello from the sheet</Text>
          <Button title="Close" onPress={close} />
        </BottomSheetView>
      </BottomSheet>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 24, paddingTop: 80 },
  content: { flex: 1, padding: 24, alignItems: 'center' },
  title: { fontSize: 20, fontWeight: '600', marginBottom: 16 },
});

Three things to notice:

  1. snapPoints is memoized. If you pass a fresh array on every render, the sheet will reset its position. Always wrap it in useMemo or define it at module scope.
  2. BottomSheetView wraps the content. This isn't optional — it's the connector that propagates layout and gesture context. Using a plain View will break dynamic sizing.
  3. index={-1} means closed. An index of 0 opens to the first snap point on mount.

Snap points: percentages, pixels, and CONTENT_HEIGHT

Snap points accept three formats, and you can mix them freely:

  • Percentages like '50%' — relative to the parent container (usually the screen).
  • Numeric pixels like 200 — useful for fixed-height sheets such as action menus.
  • Dynamic content height — pass enableDynamicSizing and the sheet will measure its child and snap to that height. This is the cleanest approach for sheets whose height depends on the content (a confirm dialog, a comment composer, an action list of variable length).
<BottomSheet
  ref={sheetRef}
  index={0}
  enableDynamicSizing
  enablePanDownToClose
>
  <BottomSheetView>
    {/* The sheet's height equals this view's measured height */}
    <ActionList />
  </BottomSheetView>
</BottomSheet>

Dynamic sizing was added in version 4 and stabilized in version 5. If you're still on an older release, upgrade. The difference between hand-tuned percentages and self-measuring sheets is, frankly, large.

BottomSheet vs BottomSheetModal: which to use

BottomSheet is rendered inline with your screen tree. It's fine for persistent UIs (a "currently playing" sheet on a music app) and for screens where exactly one sheet exists.

BottomSheetModal, on the other hand, is rendered through a portal into BottomSheetModalProvider at the root of your app. It's what you want for transient sheets that can be opened from anywhere — share menus, filters, action sheets — because it stacks correctly on top of any screen, navigation transition, or other modal.

import { useRef, useCallback } from 'react';
import { Button } from 'react-native';
import {
  BottomSheetModal,
  BottomSheetView,
} from '@gorhom/bottom-sheet';

export function ShareButton() {
  const modalRef = useRef<BottomSheetModal>(null);

  const present = useCallback(() => modalRef.current?.present(), []);
  const dismiss = useCallback(() => modalRef.current?.dismiss(), []);

  return (
    <>
      <Button title="Share" onPress={present} />

      <BottomSheetModal
        ref={modalRef}
        snapPoints={['40%']}
        enablePanDownToClose
        onDismiss={() => console.log('dismissed')}
      >
        <BottomSheetView>
          {/* share options */}
        </BottomSheetView>
      </BottomSheetModal>
    </>
  );
}

Note the API difference: BottomSheetModal uses present() and dismiss() instead of snapToIndex() and close(). The mental model is "this sheet exists outside the tree and I'm summoning it," which fits transient UIs better.

Adding a backdrop

By default, the area behind the sheet is fully interactive — users can tap right through to whatever is underneath. For most use cases you want a dimmed, tappable backdrop that closes the sheet. The library ships BottomSheetBackdrop for exactly this:

import { useCallback } from 'react';
import {
  BottomSheetModal,
  BottomSheetBackdrop,
  BottomSheetBackdropProps,
} from '@gorhom/bottom-sheet';

const renderBackdrop = useCallback(
  (props: BottomSheetBackdropProps) => (
    <BottomSheetBackdrop
      {...props}
      appearsOnIndex={0}
      disappearsOnIndex={-1}
      opacity={0.5}
      pressBehavior="close"
    />
  ),
  []
);

return (
  <BottomSheetModal
    ref={modalRef}
    snapPoints={['40%']}
    backdropComponent={renderBackdrop}
    enablePanDownToClose
  >
    {/* ... */}
  </BottomSheetModal>
);

The pressBehavior prop accepts 'none', 'close', 'collapse', or a numeric snap index. Use 'collapse' when you want a tap to drop the sheet to its lowest snap point rather than dismiss it entirely — that's the Apple Maps pattern.

Scrollable content: BottomSheetFlatList and BottomSheetScrollView

This, right here, is the single biggest source of bugs in real apps. You can't use a plain FlatList or ScrollView inside a sheet, because the sheet's drag gesture and the list's scroll gesture will fight each other. The library ships gesture-aware wrappers:

  • BottomSheetScrollView
  • BottomSheetFlatList
  • BottomSheetSectionList
  • BottomSheetVirtualizedList
  • BottomSheetTextInput

Use them in place of the React Native primitives whenever the component is rendered inside a sheet:

import { BottomSheetModal, BottomSheetFlatList } from '@gorhom/bottom-sheet';

<BottomSheetModal ref={modalRef} snapPoints={['90%']}>
  <BottomSheetFlatList
    data={items}
    keyExtractor={(i) => i.id}
    renderItem={({ item }) => <Row item={item} />}
    contentContainerStyle={{ paddingBottom: 32 }}
  />
</BottomSheetModal>

The wrappers configure NativeViewGestureHandler internally so the sheet pans only when the list is at its top scroll position; otherwise the list scrolls. This is exactly how native bottom sheets behave on iOS and Android.

What about FlashList?

If you're using FlashList v2 for performance (covered in our FlashList migration guide), wire it up with the gesture wrapper exposed in react-native-gesture-handler:

import { FlashList } from '@shopify/flash-list';
import { useBottomSheetInternal } from '@gorhom/bottom-sheet';
import Animated from 'react-native-reanimated';

const AnimatedFlashList = Animated.createAnimatedComponent(FlashList);

// Inside the sheet:
<AnimatedFlashList
  data={items}
  estimatedItemSize={64}
  renderItem={({ item }) => <Row item={item} />}
/>

Pair it with useBottomSheetInternal to forward the active scroll position so the sheet's pan still works at the list's top edge.

Keyboard handling

Putting a TextInput in a sheet without preparation produces one of two miseries: the keyboard covers the input, or the sheet collapses when the keyboard appears. Neither is great. The fix is to use BottomSheetTextInput and let the library handle keyboard avoidance:

import { BottomSheetModal, BottomSheetView, BottomSheetTextInput } from '@gorhom/bottom-sheet';

<BottomSheetModal
  ref={modalRef}
  snapPoints={['50%']}
  keyboardBehavior="interactive"
  keyboardBlurBehavior="restore"
  android_keyboardInputMode="adjustResize"
>
  <BottomSheetView style={{ padding: 16 }}>
    <BottomSheetTextInput
      placeholder="Add a comment..."
      style={{ borderWidth: 1, borderRadius: 8, padding: 12 }}
    />
  </BottomSheetView>
</BottomSheetModal>

The three keyboard props are worth memorizing:

  • keyboardBehavior="interactive" — the sheet expands as the keyboard rises, so the input stays visible.
  • keyboardBlurBehavior="restore" — when the keyboard dismisses, the sheet returns to its previous snap point instead of staying in the expanded position.
  • android_keyboardInputMode="adjustResize" — required on Android for interactive to work correctly. Without it, the keyboard pushes the entire window up rather than the sheet.

Programmatic control with refs

The ref API gives you fine-grained control:

// BottomSheet
sheetRef.current?.snapToIndex(0);    // open to first snap point
sheetRef.current?.snapToPosition('60%');  // arbitrary position
sheetRef.current?.expand();          // top snap point
sheetRef.current?.collapse();        // lowest snap point
sheetRef.current?.close();           // close

// BottomSheetModal
modalRef.current?.present();         // open
modalRef.current?.dismiss();         // close
modalRef.current?.snapToIndex(2);    // change height while open

For complex flows — "open the share sheet, then open the success sheet on top" — combine these with onChange and onDismiss callbacks to chain transitions.

Animating outside content with the sheet's position

Honestly, one of the biggest reasons to choose @gorhom/bottom-sheet over a homemade modal is that you can animate other parts of the screen in lockstep with the sheet. The library exposes the sheet's animated position as a Reanimated shared value:

import { useSharedValue, useAnimatedStyle, interpolate } from 'react-native-reanimated';
import BottomSheet from '@gorhom/bottom-sheet';
import Animated from 'react-native-reanimated';

const animatedPosition = useSharedValue(0);

const fabStyle = useAnimatedStyle(() => {
  // Fade and lift a floating button as the sheet opens
  const opacity = interpolate(animatedPosition.value, [SCREEN_HEIGHT, 0], [1, 0]);
  return { opacity };
});

return (
  <>
    <Animated.View style={[styles.fab, fabStyle]} />
    <BottomSheet
      ref={sheetRef}
      snapPoints={snapPoints}
      animatedPosition={animatedPosition}
    />
  </>
);

This is exactly how Apple Maps fades its zoom buttons and Spotify shrinks the album art — the surrounding UI reacts to the sheet's drag in real time, on the UI thread, with no jank.

Theming, dark mode, and custom handles

Override backgroundStyle and handleIndicatorStyle for theming, and pass a handleComponent when you need a fully custom handle (for example, a drag affordance with a label):

const isDark = useColorScheme() === 'dark';

<BottomSheetModal
  ref={modalRef}
  snapPoints={['50%']}
  backgroundStyle={{
    backgroundColor: isDark ? '#1c1c1e' : '#ffffff',
  }}
  handleIndicatorStyle={{
    backgroundColor: isDark ? '#48484a' : '#c7c7cc',
    width: 40,
  }}
>
  {/* ... */}
</BottomSheetModal>

For full custom backgrounds (rounded corners that match your design system, gradients, blur), pass backgroundComponent:

import { BlurView } from 'expo-blur';

const renderBackground = useCallback(
  (props: any) => (
    <BlurView
      {...props}
      intensity={80}
      tint="systemChromeMaterial"
      style={[props.style, { borderTopLeftRadius: 24, borderTopRightRadius: 24, overflow: 'hidden' }]}
    />
  ),
  []
);

<BottomSheetModal backgroundComponent={renderBackground} {...rest} />

Accessibility

Bottom sheets are notorious for skipping accessibility, and most app store rejections in 2026 cite VoiceOver and TalkBack support. Three things will get you most of the way there:

  1. Label the handle. Pass accessible and accessibilityLabel to handleComponent so screen readers announce "Drag handle, double tap and hold to drag."
  2. Trap focus. When the sheet opens, screen reader focus should move into it. Use the onAnimate callback to call AccessibilityInfo.setAccessibilityFocus on the first focusable child.
  3. Restore focus on dismiss. Save the previously-focused element and restore it in onDismiss.

The library doesn't do this for you automatically because the right behavior depends on your content. But the hooks are all there, and getting it right is a 20-line investment that pays off forever.

Performance tips

  • Always memoize snapPoints. A new array on every render is the most common cause of "the sheet jumps when state changes." (Ask me how I know.)
  • Wrap callbacks in useCallback. Especially backdropComponent and handleComponent — recreating these on every render forces the sheet to rebuild its gesture tree.
  • Prefer BottomSheetModal for transient sheets. An always-mounted BottomSheet with index={-1} still keeps its children mounted; a modal can be lazy.
  • Use the New Architecture (Fabric). Version 5 of the library was tuned for it and the gesture-to-frame latency is meaningfully lower than the legacy renderer. If you're still on the old architecture, our performance guide covers the migration.
  • Watch for autoDismissAfterDuration regressions. Setting it on a sheet whose content is also state-driven can cause double-dismiss bugs — prefer driving dismissal from your own state machine.

Common pitfalls and how to fix them

"The sheet doesn't pan on Android"

You're missing GestureHandlerRootView at the root of your app. iOS forgives this; Android doesn't.

"My ScrollView/FlatList swipes the sheet instead of scrolling"

You're using a plain ScrollView or FlatList inside the sheet. Replace with BottomSheetScrollView or BottomSheetFlatList.

"The sheet dismisses when the keyboard opens"

Set keyboardBehavior="interactive" and on Android add android_keyboardInputMode="adjustResize". Also use BottomSheetTextInput instead of plain TextInput.

"snapPoints flicker on first render"

Wrap them in useMemo, or just define them outside the component. Inline arrays trigger reconciliation.

"The backdrop appears even when the sheet is closed"

You forgot disappearsOnIndex={-1}. The default is 0, which means the backdrop stays visible until the sheet is below the first snap point.

"It works in Expo Go but not in production"

Reanimated requires a custom development build for some features (worklets that touch the JS runtime). Run npx expo prebuild and rebuild with EAS — covered in our EAS deployment guide.

A complete real-world example: a filter sheet

Okay, here's a full filter sheet pattern, the kind you'd see in a shopping or food-delivery app. It combines snap points, a backdrop, scrollable content, and a docked footer with an "Apply" button. Drop it in, swap the categories for whatever your app needs, and you've got a starting point.

import { useRef, useMemo, useCallback, useState } from 'react';
import { Text, Pressable, StyleSheet } from 'react-native';
import {
  BottomSheetModal,
  BottomSheetBackdrop,
  BottomSheetView,
  BottomSheetScrollView,
  BottomSheetFooter,
  type BottomSheetBackdropProps,
} from '@gorhom/bottom-sheet';

const CATEGORIES = ['Pizza', 'Sushi', 'Burgers', 'Salads', 'Tacos', 'Ramen'];

export function FilterSheet({
  onApply,
}: {
  onApply: (selected: string[]) => void;
}) {
  const ref = useRef<BottomSheetModal>(null);
  const snapPoints = useMemo(() => ['60%', '90%'], []);
  const [selected, setSelected] = useState<string[]>([]);

  const renderBackdrop = useCallback(
    (props: BottomSheetBackdropProps) => (
      <BottomSheetBackdrop
        {...props}
        appearsOnIndex={0}
        disappearsOnIndex={-1}
        pressBehavior="close"
      />
    ),
    []
  );

  const renderFooter = useCallback(
    (props: any) => (
      <BottomSheetFooter {...props} bottomInset={24}>
        <Pressable
          style={styles.applyButton}
          onPress={() => {
            onApply(selected);
            ref.current?.dismiss();
          }}
        >
          <Text style={styles.applyText}>Apply ({selected.length})</Text>
        </Pressable>
      </BottomSheetFooter>
    ),
    [selected, onApply]
  );

  const toggle = (cat: string) =>
    setSelected((s) =>
      s.includes(cat) ? s.filter((c) => c !== cat) : [...s, cat]
    );

  return (
    <>
      <Pressable onPress={() => ref.current?.present()} style={styles.openBtn}>
        <Text>Filters</Text>
      </Pressable>

      <BottomSheetModal
        ref={ref}
        snapPoints={snapPoints}
        backdropComponent={renderBackdrop}
        footerComponent={renderFooter}
        enablePanDownToClose
      >
        <BottomSheetView style={styles.header}>
          <Text style={styles.title}>Filters</Text>
        </BottomSheetView>

        <BottomSheetScrollView contentContainerStyle={{ padding: 16, paddingBottom: 96 }}>
          {CATEGORIES.map((cat) => {
            const active = selected.includes(cat);
            return (
              <Pressable
                key={cat}
                onPress={() => toggle(cat)}
                style={[styles.row, active && styles.rowActive]}
              >
                <Text style={[styles.rowText, active && styles.rowTextActive]}>{cat}</Text>
              </Pressable>
            );
          })}
        </BottomSheetScrollView>
      </BottomSheetModal>
    </>
  );
}

const styles = StyleSheet.create({
  openBtn: { padding: 12, borderWidth: 1, borderRadius: 8 },
  header: { padding: 16, borderBottomWidth: StyleSheet.hairlineWidth },
  title: { fontSize: 18, fontWeight: '600' },
  row: {
    paddingVertical: 14,
    paddingHorizontal: 16,
    borderRadius: 12,
    marginBottom: 8,
    backgroundColor: '#f2f2f7',
  },
  rowActive: { backgroundColor: '#007aff' },
  rowText: { fontSize: 16 },
  rowTextActive: { color: 'white', fontWeight: '600' },
  applyButton: {
    backgroundColor: '#007aff',
    padding: 16,
    marginHorizontal: 16,
    borderRadius: 12,
    alignItems: 'center',
  },
  applyText: { color: 'white', fontWeight: '600', fontSize: 16 },
});

This is the foundation of nearly every filter sheet in production apps. Swap the rows for radio buttons, sliders, or date pickers and the structure stays the same.

Frequently Asked Questions

Is @gorhom/bottom-sheet free to use?

Yes. The library is MIT-licensed, free for commercial use, and has no paid tier. The maintainer accepts sponsorships through GitHub Sponsors, but the library itself is fully open source.

Does @gorhom/bottom-sheet work with Expo Go?

Mostly. Basic usage works in Expo Go because Reanimated and Gesture Handler ship with the SDK. If you need Reanimated worklets that pull in additional native code (rare for bottom sheets), or if you customize the native handle, you'll need a development build via npx expo prebuild and eas build --profile development.

What's the difference between BottomSheet and BottomSheetModal?

BottomSheet renders inline in your component tree and is controlled with snapToIndex(n) / close(). It's right for a single, persistent sheet on a screen. BottomSheetModal renders through a portal at the app root and is controlled with present() / dismiss(). It's right for transient sheets that can be opened from anywhere and need to stack above navigation transitions.

How do I prevent the bottom sheet from being dismissed?

Set enablePanDownToClose={false} to disable the swipe-down gesture, omit the backdrop's pressBehavior="close", and don't call close() or dismiss() from your code. For modals you should also set enableDismissOnClose={false} if you want the modal to remain mounted while collapsed.

Can I use a bottom sheet inside a React Navigation stack?

Yes — this is the recommended pattern for sheets that should appear above the screen and stack with system gestures. Place BottomSheetModalProvider outside NavigationContainer (or above the Expo Router Stack) so modals render on top of every navigation transition. For sheet-based routes rather than overlays, Expo Router's native Stack.Screen options={{ presentation: 'formSheet' }} uses iOS's UISheetPresentationController natively, which can be a better fit for full-screen routes that should feel like sheets.

Is there a New Architecture (Fabric) version?

Yes. @gorhom/bottom-sheet v5 supports the React Native New Architecture out of the box. If you're migrating from v4, the public API is largely unchanged — the breaking changes are around prop names for some custom components. Pin to v5+ if you've already enabled Fabric in your Expo SDK 55 project.

Wrapping up

A bottom sheet is one of those primitives that looks simple from the outside but has dozens of edge cases — gestures fighting scrolls, keyboards swallowing inputs, snap points jittering on re-render. @gorhom/bottom-sheet handles all of them in a way that feels native, and once you learn the handful of conventions covered above (memoize snap points, use the BottomSheet* wrappers for content, set keyboard mode on Android, prefer modals for transient UIs) you can ship sheets confidently.

If you're building a media app, an e-commerce app, a maps app, or anything with a list-detail flow, a bottom sheet is probably already on your design board. Start with the filter-sheet example above, swap in your content, and iterate from there. You'll be surprised how quickly the rough edges disappear.

About the Author Editorial Team

Our team of expert writers and editors.