Skip to content

Latest commit

 

History

History
354 lines (287 loc) · 11.2 KB

File metadata and controls

354 lines (287 loc) · 11.2 KB

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. Use scrimColor to customize its 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, whatever order the detents are passed in.

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>

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.

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>

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'].

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>

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.

<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 (the distance in pixels from the bottom of the screen to the top of the sheet).

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

If you want 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={(nextPosition) => {
    position.value = nextPosition;
  }}
>
  {/* ... */}
</BottomSheet>

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.