Skip to main content

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

TokenValue
ContainersurfaceContainerLow
CornerTop 28dp (cornerExtraLarge)
ElevationLevel 1 (modal and standard)
WidthFills the window, max 640dp, centered
Drag handle32×4dp, onSurfaceVariant, in a 48dp touch strip
Scrim (modal)scrim @ 32%
Settle thresholds56dp positional, 125dp/s velocity

Intentional deviation. Older MD3 spec pages draw the drag handle at 40% opacity. Current compose (Expressive-updated) uses onSurfaceVariant at full opacity, and per the library's audit convention the Expressive value wins.

Accessibility

  • The modal surface reports role="dialog", aria-modal, and accessibilityViewIsModal, 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 when dismissable={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 dismissable sheet 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

PropTypeDefaultRequiredDescription
visibleboolean-YesWhether the sheet is shown. Exit animations run before it unmounts.
onDismiss() => void-YesCalled 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.
childrenReactNode-NoSheet content. Renders below the drag handle; scrolling is yours.
variantenummodalNoSheet behavior.
snapPointsBottomSheetSnapPoint[]-NoResting 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.
snapIndexnumber-NoSnap index to settle at. Changing it animates the sheet there; the user can still drag to other snaps, reported through `onSnapIndexChange`.
defaultSnapIndexnumber0NoSnap index the sheet opens at.
onSnapIndexChange(index: number) => void-NoCalled when the sheet settles at a different snap point.
dismissablebooleanNoWhether 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.
showDragHandlebooleanNoWhether the drag handle is shown. Without it the sheet cannot be dragged — dismissal is limited to the scrim and the back button.
insetBottombooleanNoWhether the sheet's content is padded by the bottom safe-area inset. The container color still extends under the inset.
containerColorstringsurfaceContainerLowNoOverride the container (surface) color.
hostNamestring-NoRender into a specific named `PortalHost`.
styleStyleProp<ViewStyle>-NoStyle applied to the sheet surface.
scrimStyleStyleProp<ViewStyle>-NoStyle applied to the scrim. Ignored by the standard variant.
scrimAccessibilityLabelstringClose sheetNoScreen-reader label for the scrim's dismiss action.
dragHandleAccessibilityLabelstringDrag handleNoScreen-reader label for the drag handle.
testIDstring-NoTest id applied to the sheet surface.
onKeyDown(event: { nativeEvent: { key?: string; }; }) => void-No-