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

