BottomSheet
Bottom sheets surface supplementary content anchored to the bottom edge, dragged between snap points or dismissed with a downward fling. Follows the Material Design 3 Bottom sheet specification.
BottomSheet renders through Portal, so your app must be wrapped
in a PortalHost once at the root. It claims the PORTAL_LAYERS.sheet layer —
the lowest overlay layer, beneath dialogs, snackbars, menus, and tooltips.
Percentage snap points resolve against that host's height, so a root-level host
gives the sheet the whole window.
Usage
import { BottomSheet, Button, PortalHost, Typography } from '@rootnative/components'
import { ThemeProvider } from '@rootnative/core'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { useState } from 'react'
import { View } from 'react-native'
function Screen() {
const [open, setOpen] = useState(false)
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<Button onPress={() => setOpen(true)}>Share</Button>
<BottomSheet visible={open} onDismiss={() => setOpen(false)}>
<View style={{ padding: 24, paddingTop: 0, gap: 12 }}>
<Typography variant="titleMedium">Share file</Typography>
<Typography variant="bodyMedium">
Drag the handle down, tap the scrim, or press the Android back
button to dismiss.
</Typography>
<Button variant="text" onPress={() => setOpen(false)}>Cancel</Button>
</View>
</BottomSheet>
</View>
)
}
export default function App() {
return (
<SafeAreaProvider>
<ThemeProvider>
<PortalHost>
<Screen />
</PortalHost>
</ThemeProvider>
</SafeAreaProvider>
)
}
Without snapPoints the sheet sizes itself to its content (capped at the host's
height) and has a single resting position. Content renders below the built-in
drag handle; the container color extends under the bottom safe-area inset while
the content is padded above it (disable with insetBottom={false}).
Snap points
snapPoints are the sheet's resting heights — numbers in dp, or percentage
strings of the portal host's height. The sheet is sized to the tallest one and
translates between them. Indices (for snapIndex, defaultSnapIndex, and
onSnapIndexChange) refer to the points sorted ascending by resolved height,
so 0 is always the lowest.
import { BottomSheet, Button, PortalHost, Typography } from '@rootnative/components'
import { ThemeProvider } from '@rootnative/core'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { useState } from 'react'
import { ScrollView, View } from 'react-native'
function Screen() {
const [open, setOpen] = useState(false)
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<Button onPress={() => setOpen(true)}>Nearby places</Button>
<BottomSheet
visible={open}
onDismiss={() => setOpen(false)}
snapPoints={['50%', '90%']}
>
<ScrollView>
<View style={{ padding: 24, paddingTop: 0, gap: 12 }}>
<Typography variant="titleMedium">Nearby places</Typography>
{Array.from({ length: 20 }, (_, i) => (
<Typography key={i} variant="bodyMedium">Place {i + 1}</Typography>
))}
</View>
</ScrollView>
</BottomSheet>
</View>
)
}
export default function App() {
return (
<SafeAreaProvider>
<ThemeProvider>
<PortalHost>
<Screen />
</PortalHost>
</ThemeProvider>
</SafeAreaProvider>
)
}
A release settles using the MD3 thresholds: a fling faster than 125dp/s commits to the next snap in the fling's direction, a slow drag that moved at least 56dp settles at the nearest snap, and anything less springs back. Below the lowest snap point, settling dismisses the sheet.
Change snapIndex to move the sheet programmatically — the user can still drag
to other snaps, reported through onSnapIndexChange.
Standard variant
variant="standard" drops the scrim and the modal semantics: the screen behind
the sheet stays visible and interactive, per the MD3 standard bottom sheet. The
Android back button is left to navigation, and dismissal happens by dragging the
handle below the lowest snap (or programmatically).
Dragging and scrolling content
The drag gesture lives on the handle's 48dp touch strip, not on the sheet body —
so a ScrollView inside the sheet scrolls freely without fighting the drag.
Give scrollable content its own ScrollView as in the example above; the
surface bounds it and it scrolls within the sheet. Hiding the handle with
showDragHandle={false} therefore also removes dragging — dismissal falls back
to the scrim and the back button.
Dragging above the tallest snap point rubber-bands, as does dragging below the
lowest one when dismissable={false}.
Dismissal
onDismiss fires for the user-initiated ways out — a settle below the lowest
snap point, a scrim tap, the Android hardware back button, and the drag handle's
dismiss accessibility action. Set visible to false in response; the sheet
runs its exit slide and unmounts.
Set dismissable={false} when the user has to resolve the sheet with one of its
actions: the scrim stops responding (and stops being announced), the back button
falls through to navigation, and downward drags rubber-band back to the lowest
snap.
Motion
The sheet slides in from the bottom edge on springDefaultSpatial and settles
after a drag on the same spring, seeded with the release velocity. The scrim
fades on springFastEffects. Every one of these collapses to a hard cut under
reduced motion — see Motion. Position tracking during a drag
follows the finger 1:1 and is unaffected.
Tokens
| Token | Value |
|---|---|
| Container | surfaceContainerLow |
| Corner | Top 28dp (cornerExtraLarge) |
| Elevation | Level 1 (modal and standard) |
| Width | Fills the window, max 640dp, centered |
| Drag handle | 32×4dp, onSurfaceVariant, in a 48dp touch strip |
| Scrim (modal) | scrim @ 32% |
| Settle thresholds | 56dp positional, 125dp/s velocity |
Intentional deviation. Older MD3 spec pages draw the drag handle at 40% opacity. Current compose (Expressive-updated) uses
onSurfaceVariantat full opacity, and per the library's audit convention the Expressive value wins.
Accessibility
- The modal surface reports
role="dialog",aria-modal, andaccessibilityViewIsModal, so assistive tech treats the content beneath as inert. The standard variant reports none of these. - The scrim is announced as a button labelled "Close sheet" — override with
scrimAccessibilityLabel. It disappears from the accessibility tree whendismissable={false}. - The drag handle announces as adjustable ("Drag handle", override with
dragHandleAccessibilityLabel) with its snap position as the value. Increment/decrement actions expand and collapse between snap points, and an escape action dismisses. - On web the handle is focusable: arrow keys move between snap points and Escape dismisses.
- On web, the modal variant also contains keyboard focus — focus moves into the sheet when it opens, Tab cycles inside it, Escape dismisses a
dismissablesheet from anywhere in it, and focus returns to the opening control on close. The standard variant does none of this by design, since it lets the content behind it stay usable. See Accessibility.
Props
| Prop | Type | Default | Required | Description |
|---|---|---|---|---|
visible | boolean | - | Yes | Whether the sheet is shown. Exit animations run before it unmounts. |
onDismiss | () => void | - | Yes | Called when the user dismisses the sheet — a drag below the lowest snap point, a scrim tap, or the Android back button. Set `visible` to `false` in response, or the sheet stays mounted off screen. |
children | ReactNode | - | No | Sheet content. Renders below the drag handle; scrolling is yours. |
variant | enum | modal | No | Sheet behavior. |
snapPoints | BottomSheetSnapPoint[] | - | No | Resting heights the sheet snaps to, e.g. `[240, '90%']`. The sheet is sized to the tallest one; indices used by `snapIndex` / `defaultSnapIndex` / `onSnapIndexChange` refer to the points sorted ascending by resolved height. Omit to size the sheet to its content with a single snap. |
snapIndex | number | - | No | Snap index to settle at. Changing it animates the sheet there; the user can still drag to other snaps, reported through `onSnapIndexChange`. |
defaultSnapIndex | number | 0 | No | Snap index the sheet opens at. |
onSnapIndexChange | (index: number) => void | - | No | Called when the sheet settles at a different snap point. |
dismissable | boolean | | No | Whether the scrim tap, the Android back button, and a downward drag past the lowest snap point dismiss the sheet. When `false`, drags below the lowest snap rubber-band back. |
showDragHandle | boolean | | No | Whether the drag handle is shown. Without it the sheet cannot be dragged — dismissal is limited to the scrim and the back button. |
insetBottom | boolean | | No | Whether the sheet's content is padded by the bottom safe-area inset. The container color still extends under the inset. |
containerColor | string | surfaceContainerLow | No | Override the container (surface) color. |
hostName | string | - | No | Render into a specific named `PortalHost`. |
style | StyleProp<ViewStyle> | - | No | Style applied to the sheet surface. |
scrimStyle | StyleProp<ViewStyle> | - | No | Style applied to the scrim. Ignored by the standard variant. |
scrimAccessibilityLabel | string | Close sheet | No | Screen-reader label for the scrim's dismiss action. |
dragHandleAccessibilityLabel | string | Drag handle | No | Screen-reader label for the drag handle. |
testID | string | - | No | Test id applied to the sheet surface. |
onKeyDown | (event: { nativeEvent: { key?: string; }; }) => void | - | No | - |