modules/bottom-sheet/README.md
A custom Expo module that provides native bottom sheet functionality for iOS and Android, using platform-specific native bottom sheet implementations (UISheetPresentationController on iOS, Material BottomSheetDialog on Android).
This module wraps native bottom sheet components to provide a React Native interface with cross-platform consistency. It uses native presentation APIs rather than JavaScript-based animations for better performance and native behavior.
Key features:
UISheetPresentationController (iOS 15+)BottomSheetDialog with BottomSheetBehaviorThe module exposes a React component that handles rendering and state management:
The component uses a class-based approach to expose imperative methods (present(), dismiss(), dismissAll()).
SheetViewController
UISheetPresentationControllerDelegate for drag eventsOnLayoutChangeListener to observe content height nativelyBottomSheetBehavior for drag and snap behaviorBottomSheetView so the content canvas can follow itUIManagerModule.updateNodeSize() shadow node sizing, which only runs on the old architectureBoth platforms detect content height changes natively without JS bridge round-trips:
bounds propertyOnLayoutChangeListener on child views (catches React Native's direct layout() calls)This eliminates layout jank when content changes (e.g., keyboard appearance, dynamic content loading).
The "canvas" is the size the sheet content is laid out on by Yoga. On Android the native side owns it; on iOS it is still sized from JS.
flex: 1 content and BottomSheetView pushes the canvas size into the Fabric shadow tree through ExpoView's setViewSize state channel (shadowNodeProxy.setViewSize()). Only native knows the real sheet frame - Material caps the frame at 640dp on tablets and centers it, and it changes on rotation.BottomSheetNativeComponent sets height: screenHeight - insets.top and width: '100%' on the native view. Moving iOS onto the same state channel is deferred: it needs on-device iteration on iOS 26 sheet geometry (large-detent and floating-card metrics, where the visible sheet is shorter than the window minus the top inset).How the Android path works:
width or height on Android. ExpoViewComponentDescriptor::adopt() only applies the state size on an axis where the style leaves that dimension undefined, so a style dimension would silently win.DialogRootViewGroup's size-change listener - that is the real sheet width, with the horizontal window insets and Material's 640dp cap already applied. It is seeded from min(window width, material_bottom_sheet_max_width) on the first onLayout so content has something to lay out in before the dialog exists.screenHeight - statusBarHeight (matching the behavior's expandedOffset) - the whole expanded frame, never the dialog's measured height. The canvas has to be room for the content to grow into, because the content's height is what drives the snap points. Sizing it from the dialog's own height is circular: BottomSheetBehavior measures the container against the sheet, so the canvas collapses onto the content height and the content is then pinned - extra ScrollView padding (the Android keyboard path) or a longer list becomes scroll extent instead of a height change, OnLayoutChangeListener never fires, and the sheet stops responding to its content.present() bails out early when the content height is still zero. The commit resizes the native view, which re-fires onLayout, which re-enters present() - so presentation self-retries rather than needing an explicit callback. Full-height sheets skip the check, since they don't need a content measurement.screenHeight is read per access so the computed height follows the rotation, and the container reports the new width (plus a deferred updateLayout() to reposition the sheet).stateWrapper is null, so setViewSize no-ops) and Android falls back to DialogRootViewGroup's legacy UIManagerModule.updateNodeSize() path. The present() gate is skipped there for the same reason - nothing would ever resize the view.interface BottomSheetViewProps {
children: React.ReactNode
// Appearance
cornerRadius?: number
backgroundColor?: ColorValue
containerBackgroundColor?: ColorValue
// Behavior
preventDismiss?: boolean // Disable swipe-to-dismiss
preventExpansion?: boolean // Lock to initial height (no full-screen)
disableDrag?: boolean // Disable drag handle (Android only)
fullHeight?: boolean // Start at full screen height
// Height constraints
minHeight?: number // Minimum height in dp
maxHeight?: number // Maximum height in dp
// iOS 26+ transition
sourceViewTag?: number // View tag for zoom transition origin
// Events
onAttemptDismiss?: (event: BottomSheetAttemptDismissEvent) => void
onSnapPointChange?: (event: BottomSheetSnapPointChangeEvent) => void
onStateChange?: (event: BottomSheetStateChangeEvent) => void
}
closed: Sheet is dismissedclosing: Sheet is animating closedopen: Sheet is fully visibleopening: Sheet is animating openHidden (0): DismissedPartial (1): Half-expanded / content heightFull (2): Expanded to screen heightimport {BottomSheet, BottomSheetProvider, BottomSheetOutlet} from '@modules/bottom-sheet'
// In your app root:
function App() {
return (
<BottomSheetProvider>
<YourApp />
<BottomSheetOutlet />
</BottomSheetProvider>
)
}
// In a component:
function MyComponent() {
const sheetRef = useRef<BottomSheet>(null)
const openSheet = () => {
sheetRef.current?.present()
}
const closeSheet = () => {
sheetRef.current?.dismiss()
}
return (
<>
<Button onPress={openSheet} title="Open Sheet" />
<BottomSheet
ref={sheetRef}
cornerRadius={16}
backgroundColor="white"
onStateChange={(e) => console.log(e.nativeEvent.state)}
>
<View style={{padding: 20}}>
<Text>Sheet content</Text>
<Button onPress={closeSheet} title="Close" />
</View>
</BottomSheet>
</>
)
}
The module supports nesting sheets by using BottomSheetPortalProvider within sheet content:
<BottomSheet ref={outerSheetRef}>
<BottomSheetPortalProvider>
<Button onPress={() => innerSheetRef.current?.present()} />
<BottomSheet ref={innerSheetRef}>
<Text>Inner sheet content</Text>
</BottomSheet>
</BottomSheetPortalProvider>
</BottomSheet>
import {BottomSheetNativeComponent} from '@modules/bottom-sheet'
BottomSheetNativeComponent.dismissAll()
iOS 15 Compatibility: On iOS 15, custom detents are not available, so the module uses .medium() detent and applies extra styling to prevent visual issues.
iOS 26+ Zoom Transitions: When sourceViewTag is provided on iOS 26+, the sheet zooms from the specified view.
Detent Selection: The module automatically chooses between custom detents, .medium(), and .large() based on content height and screen size.
Edge-to-Edge: The module handles edge-to-edge display correctly across API levels:
currentWindowMetricsgetRealSize()Status/Nav Bar Appearance: Preserves light/dark appearance from the host activity and reapplies it to the sheet dialog.
Drag Handling: On full-height sheets with preventDismiss, dragging is disabled to prevent accidental dismissal (since there's no half-expanded snap point to land on).
Layout Updates During Gestures: Content height changes are deferred during drag gestures to prevent fighting the user's input.
Tablet Width: Material caps the sheet frame at 640dp (material_bottom_sheet_max_width, the android:maxWidth on Widget.MaterialComponents.BottomSheet) and centers it horizontally, so on tablets the sheet is narrower than the screen. BottomSheetView reads that cap from resources when seeding the canvas width, and the dialog container's measured width then corrects it - see Content Canvas Sizing.
Rotation: The RN activity handles configuration changes itself, so a rotation resizes the display without recreating BottomSheetView. Screen height is therefore read per access rather than cached, and maxHeight is stored unclamped and clamped against the current screen at use time.
overflow: hidden)preventDismiss + preventExpansion)index.ts - Public API exportssrc/BottomSheet.types.ts - TypeScript type definitionssrc/BottomSheet.tsx - Native component (re-export)src/BottomSheet.web.tsx - Web stubsrc/BottomSheetNativeComponent.tsx - Native wrapper with portal integrationsrc/BottomSheetNativeComponent.web.tsx - Web stub for native componentsrc/BottomSheetPortal.tsx - Portal context and providerssrc/lib/Portal.tsx - Generic portal implementationios/BottomSheetModule.swift - Module definitionios/SheetView.swift - Main view implementationios/SheetViewController.swift - View controller for sheet presentationios/SheetManager.swift - Singleton for tracking active sheetsios/Util.swift - Screen height utilityandroid/src/main/java/expo/modules/bottomsheet/BottomSheetModule.kt - Module definitionandroid/src/main/java/expo/modules/bottomsheet/BottomSheetView.kt - Main view implementationandroid/src/main/java/expo/modules/bottomsheet/DialogRootViewGroup.kt - Dialog root view groupandroid/src/main/java/expo/modules/bottomsheet/SheetManager.kt - Sheet tracking singletonexpo-module.config.json - Expo module configuration