Modal sheets
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>
Mounting
A closed modal sheet renders nothing by default, so its children and their
effects run only while it is presented. The sheet mounts in the same render that
index moves to an open detent, so 'content' detents measure the children
before it animates in, and it unmounts once it settles at a closed detent, after
the closing animation.
Use mount to keep the children mounted, for example to preserve a form's input
or a list's scroll position across openings:
mount | Children are mounted |
|---|---|
'open' (default) | only while the sheet is open |
'once' | from the first opening on |
'always' | from the start, even before it first opens |
<ModalBottomSheet index={index} onIndexChange={setIndex} mount="once">
<Form />
</ModalBottomSheet>
An inline BottomSheet always keeps its children mounted.
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>
Android close requests
onCloseRequest is Android only. Android system Back, a committed
predictive Back gesture, and a complete, unmodified physical-keyboard Escape
press use the same close request callback. The callback reports the request but
does not change the controlled index, choose a detent, or dismiss the sheet.
The consumer decides whether and how to close it.
To close a sheet in response, update index to a zero-height detent:
const [index, setIndex] = useState(1);
<ModalBottomSheet
detents={[0, 400]}
index={index}
onIndexChange={setIndex}
onCloseRequest={() => setIndex(0)}
>
{/* content */}
</ModalBottomSheet>;
Input behavior
The callback runs after a regular Back action or predictive Back commit. Predictive gesture progress does not animate the sheet, and cancelling the gesture does not invoke the callback. Escape emits once for a completed, unmodified physical-key press; modified, repeated, incomplete, or cancelled sequences do not emit additional requests.
When the consumer starts a visible animation to a closed target, the sheet continues to consume Back/Escape input without another callback until the animation settles. The next input is then routed to the next eligible sheet or to the host application. A no-op handler likewise consumes eligible input while leaving the sheet open.
Presentation ordering
Portal presentations in the same Android window follow their portal order. The topmost active portal receives the close request. A portal whose target is initially unresolved, already settled at zero height, or closed without a visible animation is skipped. A portal that is visibly animating closed remains active until settle, so a lower portal is not promoted early.
An active top portal without onCloseRequest still blocks callbacks on lower
portals. Back continues beyond the portal group, while Escape remains available
to normal key handling. nativeOverlay is hosted in a separate window and does
not participate in portal ordering. With a handler it forms the same modal
boundary; without one it forwards Back to the host Activity and leaves Escape in
normal key routing.
That priority is retained while the top portal remains visibly closing. With no handler, it continues to block lower callbacks until settle while preserving the same Back pass-through and normal Escape behavior.
Target eligibility
Eligibility is based on the resolved native target height, not the detent index
or current animated position. Numeric 0, 0%, and programmatic(0) are
closed targets. A content target becomes eligible only after measurement
resolves it above zero. A sheet with no zero-height detent, such as
detents={[300, 600]}, remains eligible at every target; the consumer can
unmount it or change its detents if the close request should make it disappear.
Limitations
Portal Escape handling is reliable while focus is inside the portal tree. When
focus is elsewhere, a focused view can consume Escape first, so external-focus
Escape is best effort. Use nativeOverlay with onCloseRequest when Escape
must be owned by a separate window.
On Android API 24–27, the external-focus fallback is supported for standard
React Native and AndroidX hosts based on ReactActivity, AppCompatActivity,
or ComponentActivity. Custom hosts and custom window callbacks can intercept
key events before the portal receives them.
Inline BottomSheet, iOS physical-keyboard Escape, accessibility dismiss
actions, scrim taps, drag gestures, and programmatic index changes are outside
this callback and keep their existing behavior.
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>