Skip to main content

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>

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>

React context​

BottomSheetProvider’s portal renders the sheet’s content inside BottomSheetProvider, not where ModalBottomSheet appears in your tree. Content inside the sheet can read context from providers above BottomSheetProvider, but not from providers between BottomSheetProvider and the sheet. This also applies to navigation context: With BottomSheetProvider at the app root, useNavigation() inside the sheet does not see the screen that opened it.

<ThemeContext.Provider value={theme}>
{/* Available inside sheets. */}
<BottomSheetProvider>
<UserContext.Provider value={user}>
{/* Not available inside sheets. */}
<Screen />
</UserContext.Provider>
</BottomSheetProvider>
</ThemeContext.Provider>

When a provider cannot move above BottomSheetProvider, read the value in the component that renders the sheet and provide it again inside the sheet:

const SheetOwner = () => {
const user = useContext(UserContext);
const [index, setIndex] = useState(0);

return (
<ModalBottomSheet
index={index}
onIndexChange={setIndex}
surface={/* ... */}
>
<UserContext.Provider value={user}>
<SheetContent />
</UserContext.Provider>
</ModalBottomSheet>
);
};

The sheet stays in sync: When the value changes, SheetOwner re-renders and passes the updated content to the portal.

Navigation context is internal to the navigation library, so pass what the sheet needs instead: Call useNavigation() in SheetOwner and hand the sheet content props or callbacks, such as onSelect={() => navigation.goBack()}.

With nativeOverlay, the sheet stays where it is rendered in the React tree and is moved natively, so it reads context from every provider above it.