Skip to content

software-mansion-labs/react-native-bottom-sheet

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

228 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

React Native Bottom Sheet

React Native Bottom Sheet provides bottom‍-‍sheet components for React Native.

Highlights

  • Native implementation for optimal performance.
  • Both inline and modal sheet components.
  • Bring your own sheet surface.
  • Dynamic, content‍-‍based sizing out of the box.
  • Automatic handling of vertically scrollable children.
  • Position tracking for driving UI tied to sheets.
  • Programmatic‍-‍only detents for snap points unreachable by dragging.

How it compares

React Native already has strong bottom‍-‍sheet options, but they make different tradeoffs. React Native Bottom Sheet gives you composable React Native primitives backed by native sheet mechanics: You compose the surface in React, while the sheet host, gestures, snapping, and scroll negotiation run in native code.

@gorhom/bottom-sheet is the closest match in day‍-‍to‍-‍day functionality: configurable detents, dynamic sizing, scrollable coordination, inline sheets, and modal presentation. The main difference is the implementation model. React Native Bottom Sheet moves the sheet host, gestures, snapping, and scroll negotiation into native code, so heavy React rendering and busy JS work are less likely to affect drag and snap performance. It also does not require Reanimated or React Native Gesture Handler. Because scroll coordination is native, regular React Native scrollables work inside the sheet without bottom‍-‍sheet‍-‍specific list components or wrapper factories.

Expo UI sheets, Expo Router form sheets, and native modal‍-‍sheet libraries such as True Sheet lean into platform presentation APIs. That is a good fit when you want a system‍-‍style presented sheet, but it also means the platform and presentation system decide more of the behavior. React Native Bottom Sheet is built as a lower‍-‍level sheet primitive instead: The same native implementation powers both persistent inline sheets and modal sheets, you provide the complete sheet surface in React, and detents can include app‍-‍level behavior such as programmatic‍-‍only snap points.

That difference also matters for layering. A platform‍-‍presented sheet can disable dimming and allow background interaction, but it is still drawn as a presented native sheet over the React Native view hierarchy. BottomSheet is actually inline: It renders in your screen’s React Native hierarchy and can be layered alongside nearby content. When you do need a modal, ModalBottomSheet is rendered through BottomSheetProvider’s portal rather than through a separate native window, so global UI such as toasts, menus, floating controls, or debug overlays can be arranged above or below it by where you place them relative to the provider.

Getting started

  1. Install React Native Bottom Sheet:

    npm i @swmansion/react-native-bottom-sheet
  2. Ensure the peer dependency is installed:

    npm i react-native-safe-area-context
  3. Wrap your app with BottomSheetProvider:

    const App = () => <BottomSheetProvider>{/* ... */}</BottomSheetProvider>;

Usage

The library provides two components: BottomSheet (inline) and ModalBottomSheet (modal). Both render their children as the sheet content, with a surface prop for the background behind it, and are controlled via detents, index, and onIndexChange. Use onSettle to observe when the sheet finishes moving.

Inline

BottomSheet renders within your screen layout.

const [index, setIndex] = useState(0);
const insets = useSafeAreaInsets();
<BottomSheet
  index={index}
  onIndexChange={setIndex}
  surface={
    <View style={[StyleSheet.absoluteFill, { backgroundColor: 'white' }]} />
  }
>
  <View style={{ padding: 16, paddingBottom: insets.bottom + 16 }}>
    <Text>Sheet content</Text>
  </View>
</BottomSheet>

Modal

ModalBottomSheet renders above other content with an optional scrim (transparent by default).

const [index, setIndex] = useState(0);
const insets = useSafeAreaInsets();
<ModalBottomSheet
  index={index}
  onIndexChange={setIndex}
  surface={
    <View style={[StyleSheet.absoluteFill, { backgroundColor: 'white' }]} />
  }
>
  <View style={{ padding: 16, paddingBottom: insets.bottom + 16 }}>
    <Text>Sheet content</Text>
  </View>
</ModalBottomSheet>

Scrim

Tapping the scrim collapses the sheet to the closed detent. Use scrimColor to customize the scrim color:

<ModalBottomSheet
  index={index}
  onIndexChange={setIndex}
  surface={/* ... */}
  scrimColor="rgba(0, 0, 0, 0.3)"
>
  {/* ... */}
</ModalBottomSheet>

By default, the scrim fades in as the sheet opens and then holds at full opacity, so detents above the first share the same scrim. Use scrimOpacities to control the opacity at each detent: It takes one value in 0–1 per detent, indexed to match detents, and interpolates linearly as the sheet is dragged between them. A shorter array reuses its last value for any remaining detents.

The default maps each detent to 0 when it is closed and 1 otherwise, so the scrim is transparent at any closed detent and fully opaque at every open one.

To keep the scrim deepening across every detent, pass one value per detent:

<ModalBottomSheet
  index={index}
  onIndexChange={setIndex}
  detents={[0, 300, 'content']}
  scrimColor="rgba(0, 0, 0, 0.3)"
  scrimOpacities={[0, 0.5, 1]}
  surface={/* ... */}
>
  {/* ... */}
</ModalBottomSheet>

Native overlay

By default ModalBottomSheet renders through BottomSheetProvider’s portal. That portal lives in your React tree, so a sheet opened from a screen presented as a native modal (for example, a React Navigation native-stack screen with presentation: 'modal') is confined to that screen and cannot cover it.

Set nativeOverlay to present the sheet in a native overlay above everything—including native modal screens—so it always covers the full window. On iOS, a UIWindow-attached container is used; on Android, a full-screen, edge-to-edge, transparent dialog.

<ModalBottomSheet
  nativeOverlay
  index={index}
  onIndexChange={setIndex}
  surface={/* ... */}
>
  {/* ... */}
</ModalBottomSheet>

Surface

Provide the sheet’s background through the surface prop. The library renders it behind your content and sizes it natively to cover the whole sheet, independently of the content height.

Decoupling the surface this way keeps the sheet covered as the content height changes. When content shrinks, the sheet animates to its new height without the background briefly exposing blank space behind the content.

If your sheet content animates its own height, pass animateContentHeight={false} to update the sheet position immediately when the active 'content' detent changes height.

Give the surface a filling style such as StyleSheet.absoluteFill. It is mounted in a full‍-‍size host, so a surface sized only by its own content would collapse and not show.

<BottomSheet // Or `ModalBottomSheet`.
  index={index}
  onIndexChange={setIndex}
  surface={
    <View style={[StyleSheet.absoluteFill, { backgroundColor: 'white' }]} />
  }
>
  <Text>Sheet content</Text>
</BottomSheet>

Keyboard handling

The sheet does not apply keyboard avoidance automatically. This keeps the component unopinionated and lets your app choose the keyboard strategy that matches its layout: resize content, add bottom padding, use a keyboard-aware scroll view, or rely on platform behavior.

For a 'content' detent, keyboard-driven padding changes the measured content height. If you animate that padding yourself, pass animateContentHeight={false} so the sheet follows the animated content height instead of adding its own resize animation.

<ModalBottomSheet
  detents={[0, 'content']}
  index={index}
  onIndexChange={setIndex}
  surface={/* ... */}
  animateContentHeight={false}
>
  {/* Content with keyboard-driven bottom padding. */}
</ModalBottomSheet>

Scrollable negotiation

By default, the sheet coordinates vertical gestures with nested scrollables, such as ScrollView and FlatList.

If you want gestures that start inside a nested scrollable to stay with that scrollable even when it cannot scroll any further, set disableScrollableNegotiation:

<BottomSheet
  index={index}
  onIndexChange={setIndex}
  surface={/* ... */}
  disableScrollableNegotiation
>
  {/* ... */}
</BottomSheet>

Detents and index

Detents are the points to which the sheet snaps. Each detent is either a number (a fixed height in pixels) or 'content' (the sheet’s content height, capped by the available screen height). The default detents are [0, 'content']. Pass detents in ascending order, from shortest to tallest. Fixed detents can be taller than the measured content height, so [0, 'content', 600] lets a compact content-sized sheet expand to a larger surface.

Sheet children are laid out in a flex container. For a full‍-‍height sheet, apply flex: 1 to your content and use the 'content' detent. surface is sized by the library, so flex: 1 only ever belongs on your content, never on the surface:

<BottomSheet
  // `detents` defaults to `[0, 'content']`.
  index={index}
  onIndexChange={setIndex}
  surface={
    <View style={[StyleSheet.absoluteFill, { backgroundColor: 'white' }]} />
  }
>
  <View style={{ flex: 1 }}>{/* Full-height sheet content. */}</View>
</BottomSheet>

By default, full-height detents are capped below the status bar. Set extendUnderStatusBar when the sheet should be allowed to occupy the full screen height:

<BottomSheet // Or `ModalBottomSheet`.
  extendUnderStatusBar
  index={index}
  onIndexChange={setIndex}
  surface={/* ... */}
>
  <View style={{ flex: 1 }}>{/* Full-screen sheet content. */}</View>
</BottomSheet>

The index prop is a zero‍-‍based index into the detents array. onIndexChange and onSettle have different responsibilities:

  • onIndexChange fires when a user‍-‍triggered snap is initiated: the moment a drag commits to a detent, before the animation settles. It does not fire for programmatic index changes; you already know when you make those. Treat it as the signal to update your controlled index state.
  • onSettle fires when the sheet finishes snapping to a detent, regardless of whether that snap was user‍-‍triggered or programmatic. It is the signal for the end of any movement. Use it for observability or side effects (analytics, reacting to collapse, etc.), not for updating the controlled index state.
const [index, setIndex] = useState(0);
<BottomSheet // Or `ModalBottomSheet`.
  detents={[0, 300, 'content']} // Collapsed, 300 px, content height.
  index={index}
  onIndexChange={setIndex} // Fires when a drag commits; keep state in sync.
  surface={/* ... */}
  onSettle={(nextIndex) => {
    if (nextIndex === 0) console.log('Sheet finished collapsing.');
  }}
>
  {/* ... */}
</BottomSheet>

Detents can also change over time. When you update detents, the sheet keeps the current index and animates to the updated detent height when needed.

Programmatic-only detents

If you want a detent to be reachable only via code (not by dragging), use the object form or the programmatic helper. Programmatic detents are excluded from drag snapping but can still be targeted via index updates. If the closed detent is programmatic-only, tapping the scrim does not dismiss the sheet.

<BottomSheet
  detents={[0, programmatic(300), 'content']}
  index={index}
  onIndexChange={setIndex}
  surface={/* ... */}
  onSettle={(nextIndex) => {
    console.log(`Settled at ${nextIndex}.`);
  }}
>
  {/* ... */}
</BottomSheet>

Position tracking

Use onPositionChange to observe the sheet’s current position. It is a standard native event; read the distance in pixels from the bottom of the screen to the top of the sheet from event.nativeEvent.position. The same event also carries event.nativeEvent.index—the fractional detent index in 0..(detents.length - 1) (0 at the shortest detent, 1 at the next, and so on, interpolated in between)—the continuous counterpart of onIndexChange, handy for driving a backdrop or per-detent animation without knowing the sheet’s height.

<BottomSheet // Or `ModalBottomSheet`.
  index={index}
  onIndexChange={setIndex}
  surface={/* ... */}
  onPositionChange={(event) => {
    console.log(event.nativeEvent.position, event.nativeEvent.index);
  }}
>
  {/* ... */}
</BottomSheet>

With Reanimated

To keep the latest position in a Reanimated shared value, update it from the callback:

const position = useSharedValue(0);
<BottomSheet
  index={index}
  onIndexChange={setIndex}
  surface={/* ... */}
  onPositionChange={(event) => {
    position.value = event.nativeEvent.position;
  }}
>
  {/* ... */}
</BottomSheet>

Because onPositionChange is a native event, you can also handle it on the UI thread. Pass Animated.createAnimatedComponent to wrapNativeView—the library applies it to the native sheet view—and give onPositionChange a worklet handler from useEvent:

import type { NativeSyntheticEvent } from 'react-native';
import Animated, { useEvent, useSharedValue } from 'react-native-reanimated';
import {
  BottomSheet,
  type PositionChangeEventData,
} from '@swmansion/react-native-bottom-sheet';
const position = useSharedValue(0);
const detentIndex = useSharedValue(0);

const onPositionChange = useEvent<
  NativeSyntheticEvent<PositionChangeEventData>
>(
  (event) => {
    'worklet';
    position.value = event.position;
    detentIndex.value = event.index;
  },
  ['onPositionChange']
);
<BottomSheet // Or `ModalBottomSheet`.
  index={index}
  onIndexChange={setIndex}
  surface={/* ... */}
  wrapNativeView={Animated.createAnimatedComponent}
  onPositionChange={onPositionChange}
>
  {/* ... */}
</BottomSheet>

wrapNativeView keeps the animated wrapper on the native sheet view itself, so the worklet binds on first render—for both inline and modal sheets—without the library depending on Reanimated. Pass a stable function (such as Animated.createAnimatedComponent), not an inline lambda.

Inside the worklet, Reanimated unwraps the native event, so you read event.position directly rather than event.nativeEvent.position. The handler runs on the UI thread on every frame the sheet moves.

Founded in 2012, Software Mansion is a software agency with experience in building web and mobile apps. We are core React Native contributors and experts in dealing with all kinds of React Native issues. We can help you build your next dream product‍—‍hire us.

Sponsored by Gobi Maps

The best of your city, all in one map.