
If you have ever used a mobile app that slides a panel up from the bottom of the screen — like a share menu, a filter drawer, or a set of quick actions — you have already experienced a bottom sheet in action. Implementing a Jetpack Compose BottomSheet used to involve dealing with BottomSheetDialog, DialogFragment, and a lot of verbose boilerplate. With Material3 and the modern Compose toolkit, you get purpose-built composables that handle animation, gesture detection, and state management out of the box. This guide covers both types of Compose bottom sheet, state control, customization, and ends with a complete working example you can drop directly into an Android project.
Before writing any code, it is worth understanding that Jetpack Compose offers two distinct bottom sheet composables, each designed for a different kind of interaction.
The first is ModalBottomSheet. This composable creates an overlay sheet that floats above your screen content and dims everything behind it with a scrim. It is the right choice for temporary, contextual interactions — actions the user takes on a specific piece of content, like sharing an article or choosing an option from a list. The user dismisses it by swiping down or tapping the scrim behind the sheet.
The second is BottomSheetScaffold. This embeds the sheet directly into your screen layout without any overlay or dimming effect. The sheet and your main content coexist on the screen at the same time. You configure a peek height so part of the sheet stays visible at all times, and the user can drag it up or down freely. This is ideal for split-screen style layouts like a map with a location panel below it.
Choosing the right one up front saves significant refactoring later. Contextual overlays belong in ModalBottomSheet. Always-visible panels that share the screen with main content belong in BottomSheetScaffold.
To use either bottom sheet composable, you need the Material3 library in your project. Open your app-level build.gradle.kts file and add these dependencies:
// build.gradle.kts (app level)
dependencies {
implementation(""androidx.compose.material3:material3:1.2.1"")
implementation(""androidx.activity:activity-compose:1.9.0"")
implementation(""androidx.compose.ui:ui:1.6.7"")
implementation(""androidx.compose.ui:ui-tooling-preview:1.6.7"")
implementation(""androidx.compose.material:material-icons-extended:1.6.7"")
}
After syncing, your project has access to ModalBottomSheet, BottomSheetScaffold, rememberModalBottomSheetState, and all supporting types. Both bottom sheet APIs are annotated with ExperimentalMaterial3Api, so any composable that uses them needs that opt-in annotation — otherwise the compiler will warn you.
The ModalBottomSheet composable takes an onDismissRequest callback and a content lambda. You drive its visibility from external state — a simple boolean. When your state is true, the sheet animates in from the bottom. When the user swipes it down or taps the scrim, onDismissRequest fires and that is your cue to set the boolean back to false.
One important detail: ModalBottomSheet does not manage its own visibility internally. If you forget to handle onDismissRequest by resetting the state, the sheet will keep re-appearing after every swipe.
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
BasicModalSheetDemo()
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BasicModalSheetDemo() {
var isSheetVisible by remember { mutableStateOf(false) }
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Button(onClick = { isSheetVisible = true }) {
Text(text = ""Open Bottom Sheet"")
}
}
if (isSheetVisible) {
ModalBottomSheet(
onDismissRequest = { isSheetVisible = false }
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = ""Quick Actions"",
style = MaterialTheme.typography.titleMedium
)
Text(text = ""Share with contacts"")
Text(text = ""Save to collection"")
Text(text = ""Copy link"")
Spacer(modifier = Modifier.height(24.dp))
}
}
}
}
Running this displays a centered button on the screen. Tapping it sets isSheetVisible to true, which triggers the ModalBottomSheet to animate upward from the bottom edge of the screen. The background dims with a semi-transparent scrim. The sheet shows three text options. Swiping the sheet down or tapping outside calls onDismissRequest, which sets isSheetVisible back to false and slides the sheet away.
The rememberModalBottomSheetState function gives you a SheetState object that tracks the sheet's current position and lets you trigger transitions programmatically from coroutines. This becomes essential when you want to dismiss the sheet from a button inside the sheet itself, or animate it to a specific expansion level without relying on user gesture input.
SheetState exposes suspend functions — hide() collapses and removes the sheet, show() opens it, and expand() forces it to full height if it was resting at a partial position. You call these inside a coroutine scope obtained with rememberCoroutineScope.
The parameter skipPartiallyExpanded controls whether the sheet can rest at a halfway position. Pass true and the sheet jumps directly from hidden to fully expanded, skipping any partial stop along the way.
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
SheetStateControlDemo()
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SheetStateControlDemo() {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val scope = rememberCoroutineScope()
var isVisible by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Button(onClick = { isVisible = true }) {
Text(""Show Sheet"")
}
}
if (isVisible) {
ModalBottomSheet(
onDismissRequest = { isVisible = false },
sheetState = sheetState
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = ""Confirm your selection"",
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedButton(
onClick = {
scope.launch {
sheetState.hide()
isVisible = false
}
}
) {
Text(""Cancel"")
}
Button(
onClick = {
scope.launch {
sheetState.hide()
isVisible = false
}
}
) {
Text(""Confirm"")
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}
}
When the user taps ""Show Sheet,"" the sheet animates directly to the fully expanded state, bypassing any partial position because skipPartiallyExpanded is true. Both the Cancel and Confirm buttons call sheetState.hide() inside a coroutine, which plays the slide-down animation before setting isVisible to false. This ordering matters — if you set isVisible to false before hide() finishes animating, the sheet disappears instantly without the smooth transition.
The BottomSheetScaffold composable works fundamentally differently from ModalBottomSheet. Rather than appearing as an overlay, it divides the screen between your main content and a draggable sheet anchored to the bottom. Your sheetContent lambda fills the sheet. The trailing content lambda fills everything above it, and receives innerPadding so your layout respects the sheet's resting position.
The sheetPeekHeight parameter determines how much of the sheet stays visible when it is in its collapsed resting state. Setting it to something like 72.dp means the top portion of the sheet always peeks out at the bottom, giving users a visual hint that more content can be revealed by dragging upward.
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
PersistentSheetDemo()
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PersistentSheetDemo() {
BottomSheetScaffold(
sheetContent = {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = ""Nearby Locations"",
style = MaterialTheme.typography.titleMedium
)
repeat(5) { index ->
Text(text = ""Location ${index + 1} — 0.${index + 1} km away"")
}
Spacer(modifier = Modifier.height(32.dp))
}
},
sheetPeekHeight = 72.dp
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.background(Color(0xFFE8F5E9)),
contentAlignment = Alignment.Center
) {
Text(
text = ""Map View"",
style = MaterialTheme.typography.headlineMedium
)
}
}
}
The screen renders with a green main content area labeled ""Map View"" and a white sheet peeking 72dp from the bottom, showing just the ""Nearby Locations"" header. Dragging the sheet upward reveals all five location entries. There is no scrim and no overlay — the map area remains fully visible and interactive the entire time as the sheet expands and collapses.
Both ModalBottomSheet and BottomSheetScaffold accept several visual customization parameters. The shape parameter controls the rounded corners at the top of the sheet. The containerColor sets the background of the sheet surface. The dragHandle parameter accepts any composable so you can replace the default pill indicator with a custom design — or pass an empty lambda to remove it entirely.
The tonalElevation parameter influences how Material3 applies surface color blending. Higher values add a subtle tint on dark themes, making the sheet visually distinct from the screen background without changing the explicit container color.
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
CustomSheetDemo()
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CustomSheetDemo() {
var isVisible by remember { mutableStateOf(false) }
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Button(onClick = { isVisible = true }) {
Text(""Open Styled Sheet"")
}
}
if (isVisible) {
ModalBottomSheet(
onDismissRequest = { isVisible = false },
shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
containerColor = Color(0xFF0D1117),
tonalElevation = 8.dp,
dragHandle = {
Box(
modifier = Modifier
.padding(vertical = 14.dp)
.width(36.dp)
.height(4.dp)
.background(
color = Color.White.copy(alpha = 0.3f),
shape = RoundedCornerShape(50)
)
)
}
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = ""Dark Mode Actions"",
style = MaterialTheme.typography.titleMedium,
color = Color.White
)
Text(
text = ""All options are styled for dark theme."",
style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = 0.6f)
)
Spacer(modifier = Modifier.height(16.dp))
}
}
}
}
The sheet now appears with a deep dark background, a sharper 28dp corner radius at the top, and a custom semi-transparent white drag indicator replacing the default pill. Title text is white and the body text uses reduced opacity for a natural hierarchy against the dark surface. You can apply this same pattern to match any brand design system — custom fonts, icon handles, branded colors, or even no drag handle at all.
This complete example builds a profile action sheet. It combines ModalBottomSheet with rememberModalBottomSheetState, programmatic dismissal via coroutines, a list of icon-based action rows with one destructive item rendered in error color, and live feedback shown on the main screen after the user selects an option.
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Block
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme {
ProfileScreen()
}
}
}
}
data class ProfileAction(
val label: String,
val icon: ImageVector,
val isDestructive: Boolean = false
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProfileScreen() {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val scope = rememberCoroutineScope()
var sheetVisible by remember { mutableStateOf(false) }
var lastAction by remember { mutableStateOf<String?>(null) }
val actions = listOf(
ProfileAction(""Edit Profile"", Icons.Default.Edit),
ProfileAction(""Share Profile"", Icons.Default.Share),
ProfileAction(""Follow User"", Icons.Default.PersonAdd),
ProfileAction(""Block User"", Icons.Default.Block, isDestructive = true)
)
Scaffold(
topBar = {
TopAppBar(title = { Text(""User Profile"") })
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(16.dp))
Surface(
modifier = Modifier
.size(80.dp)
.clip(CircleShape),
color = MaterialTheme.colorScheme.primaryContainer
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = ""JD"",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
Text(
text = ""Jane Doe"",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold
)
Text(
text = ""@janedoe · 1.2k followers"",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Button(onClick = { sheetVisible = true }) {
Text(""Profile Actions"")
}
lastAction?.let { action ->
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Text(
text = ""Last action: $action"",
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
if (sheetVisible) {
ModalBottomSheet(
onDismissRequest = { sheetVisible = false },
sheetState = sheetState,
shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 36.dp)
) {
Text(
text = ""Profile Actions"",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 14.dp)
)
HorizontalDivider()
actions.forEach { action ->
val contentColor = if (action.isDestructive) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurface
}
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
lastAction = action.label
scope.launch {
sheetState.hide()
sheetVisible = false
}
}
.padding(horizontal = 24.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Icon(
imageVector = action.icon,
contentDescription = action.label,
tint = contentColor
)
Text(
text = action.label,
style = MaterialTheme.typography.bodyLarge,
color = contentColor
)
}
}
}
}
}
}