React Native Bottom Sheet provides bottom-sheet components for React Native.
- 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.
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.
-
Install React Native Bottom Sheet:
npm i @swmansion/react-native-bottom-sheet
-
Ensure the peer dependency is installed:
npm i react-native-safe-area-context
-
Wrap your app with
BottomSheetProvider:const App = () => <BottomSheetProvider>{/* ... */}</BottomSheetProvider>;
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.
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>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>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>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>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 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:
onIndexChangefires when a user-triggered snap is initiated: the moment a drag commits to a detent, before the animation settles. It does not fire for programmaticindexchanges; you already know when you make those. Treat it as the signal to update your controlledindexstate.onSettlefires 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 controlledindexstate.
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.
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>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.

