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>
Lazy content
By default, a sheet’s native view, children and surface stay mounted while
it is closed. Set lazy to keep them in the tree only while the sheet is open:
<ModalBottomSheet
lazy
index={index}
onIndexChange={setIndex}
surface={/* ... */}
>
<ExpensiveContent />
</ModalBottomSheet>
The sheet mounts in the same render that index selects an open detent, so its
content is in place before it starts opening, and unmounts once it settles at a
closed detent, so the content stays visible throughout the closing animation.
Content state is reset each time the sheet opens. While unmounted, the sheet
emits no onPositionChange events. lazy is also available on BottomSheet
and has no effect when no detent is closed.
Stacking
When several modal sheets are open, the one opened most recently is on top,
whatever order they appear in your tree. Reopening a sheet, even while it is
still closing, brings it back to the top. windowOverlay sheets are always
above portal sheets and stack among themselves the same way. Back and Escape go
to the top sheet. A top sheet that does not act on them still blocks the portal
sheets it covers, so they do not reach a covered sheet. The exception is a
windowOverlay sheet on Android that does not act on Back: it forwards Back to
the app, where a portal sheet can receive it (see
Presentation ordering).
Scrim
Tapping the scrim is a close request: by default, it
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. Set a detent’s
scrimOpacity to control the opacity there: It takes a value from 0 through 1
(the library throws an Error otherwise) and interpolates linearly as the sheet
is dragged between detents.
A detent without scrimOpacity defaults 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, lower it at the shorter open ones:
<ModalBottomSheet
index={index}
onIndexChange={setIndex}
detents={[0, { value: 300, scrimOpacity: 0.5 }, 'content']}
scrimColor="rgba(0, 0, 0, 0.3)"
surface={/* ... */}
>
{/* ... */}
</ModalBottomSheet>
Accessible dismissal
Screen reader users can ask the sheet to close the same way a scrim tap does. While tapping the scrim would ask the sheet to close, the scrim above the sheet is exposed as a “Dismiss” button to VoiceOver, TalkBack, and screen readers on the web. In addition:
- On iOS, VoiceOver’s escape gesture (a two-finger Z) asks the sheet to close.
- On Android, TalkBack offers a Dismiss action (in its actions menu) while focus is inside the sheet’s content.
These paths are close requests, like a scrim tap. They
require a visible scrim—one whose opacity at the current position is above 0,
even when scrimColor is transparent—and either an onCloseRequest handler or
a non-programmatic closed detent. While the scrim is visible but neither is
there, VoiceOver’s escape gesture is consumed by the sheet instead of reaching
the screen behind it. A sheet that fills its host leaves no scrim for the
button, but the escape gesture and dismiss action remain.
Use scrimAccessibilityLabel to localize the button’s label, which defaults to
“Dismiss”:
<ModalBottomSheet
index={index}
onIndexChange={setIndex}
scrimAccessibilityLabel={t('close')}
surface={/* ... */}
>
{/* ... */}
</ModalBottomSheet>
Close requests
A close request is the user asking an open sheet to close without dragging it:
- A scrim tap.
- A screen reader’s dismiss action.
- Android system Back or a committed predictive Back gesture.
- A complete, unmodified physical-keyboard Escape press, on Android and the web.
By default, a close request snaps the sheet to its closed detent and reports it
through onIndexChange, as a drag to that detent would. That needs a visible
scrim and a closed detent that is not programmatic; otherwise the request does
nothing, and Back and Escape go on to the next sheet or the app.
Set onCloseRequest to decide yourself. The callback reports the request but
does not change the controlled index, choose a detent, or dismiss the sheet,
so you can confirm first, close a sheet that has no closed detent, or ignore the
request. Dragging the sheet closed is not a close request: it always reports
through onIndexChange.
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
A Back request is made after a regular Back action or predictive Back commit. Predictive gesture progress does not animate the sheet, and cancelling the gesture makes no request. Escape makes one request for a completed, unmodified physical-key press; modified, repeated, incomplete, or cancelled sequences make no additional requests.
While a visible sheet animates to a closed target, it continues to consume Back/Escape input without another request 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
A sheet acts on Back and Escape when it has an onCloseRequest handler or can
close by default (a visible scrim and a non-programmatic closed detent). A top
sheet that does neither still blocks the sheets it covers.
On the web, every open modal sheet follows the open order, and a top sheet that does not act on Escape leaves it to normal key handling.
Portal presentations in the same Android window follow their open 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 that does not act on close requests still blocks lower
portals. Back continues beyond the portal group, while Escape remains available
to normal key handling. windowOverlay is hosted in a separate window and does
not participate in portal ordering. When it acts on close requests it forms the
same modal boundary; otherwise 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. A portal that does not act on close requests continues to block lower ones 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 and 0%, programmatic or not, 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 windowOverlay 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, drag gestures, and
programmatic index changes are not close requests.
Window 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 windowOverlay to present the sheet in a window 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; on the web, a fixed-position layer in
document.body.
<ModalBottomSheet
windowOverlay
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 windowOverlay, the sheet stays where it is rendered in the React tree and
is moved natively, so it reads context from every provider above it.