Jetpack Compose ModalBottomSheet

If you have ever tapped a location in Google Maps and watched a panel glide up smoothly from the bottom of the screen, you have already experienced a bottom sheet in action. Jetpack Compose ModalBottomSheet is the Material 3 component that brings that exact experience to your Android app using clean, declarative Compose syntax. Whether you are building a share menu, an options drawer, or a filter panel, this component handles all the animation, scrim overlay, and gesture dismissal out of the box so you can focus on what goes inside it.

What Is Jetpack Compose ModalBottomSheet

A bottom sheet is a supplementary surface that slides up from the bottom edge of the screen and sits on top of your existing content. The modal variant — specifically the Compose bottom sheet called ModalBottomSheet — blocks all interaction with the content behind it until the user explicitly dismisses it by swiping down or tapping the dimmed scrim.

In Compose's Material 3 library, ModalBottomSheet is a composable function that manages its own animation, drag behavior, and background overlay internally. You connect it to a SheetState object that tracks whether the sheet is hidden, partially expanded, or fully expanded, and Compose handles the rest automatically.

This is different from a dialog. Dialogs are centered on screen and interrupt the user at eye level. A Compose bottom sheet flows naturally from the bottom edge and feels much lighter — perfect for secondary actions like sorting, filtering, or picking from a list of options.

Project Setup for Material 3 ModalBottomSheet

ModalBottomSheet lives inside the Material 3 library, which is a separate dependency from the older Material 2 that shipped with early Compose versions. Before writing any composables, add the following to your app-level build.gradle file:

// build.gradle.kts (app module)
dependencies {
    implementation("androidx.compose.material3:material3:1.3.1")
    implementation("androidx.compose.ui:ui:1.7.0")
    implementation("androidx.compose.foundation:foundation:1.7.0")
    implementation("androidx.activity:activity-compose:1.9.0")
}

If you started your project with a recent version of Android Studio, Material 3 may already be included. You can verify by checking whether your theme file imports MaterialTheme from the material3 package rather than the older material package. The Android bottom sheet Compose implementation requires Material 3 — the older ModalBottomSheetLayout from Material 2 has a completely different API and is not covered here.

Your First ModalBottomSheet in Compose

The best way to understand ModalBottomSheet is to build one from scratch. The composable requires two things at minimum: an onDismissRequest callback that fires when the user dismisses the sheet by gesture, and a content lambda where your sheet content lives.

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SimpleSheetDemo() {
    var showSheet by remember { mutableStateOf(false) }

    Column(modifier = Modifier.padding(16.dp)) {
        Button(onClick = { showSheet = true }) {
            Text("Open Bottom Sheet")
        }
    }

    if (showSheet) {
        ModalBottomSheet(
            onDismissRequest = { showSheet = false }
        ) {
            Column(modifier = Modifier.padding(24.dp)) {
                Text("Hello from ModalBottomSheet!")
                Text("Swipe down or tap outside to dismiss.")
            }
        }
    }
}

The boolean state variable showSheet is the visibility driver. When it is true, the ModalBottomSheet composable enters the Compose tree and animates into view from below. When the user swipes it down or taps the scrim, onDismissRequest fires and sets showSheet back to false, removing the sheet from the composition entirely. This conditional composition pattern — wrapping the Jetpack Compose ModalBottomSheet in an if block — is the standard Compose approach for transient UI that needs to appear and disappear.

Understanding SheetState and rememberModalBottomSheetState

The SheetState is the object that tracks and drives the sheet's current position. It knows whether the sheet is Hidden, PartiallyExpanded, or Expanded, and it exposes the coroutine-based functions you call when you want to control the sheet from code.

You create a SheetState using rememberModalBottomSheetState, which keeps the state alive across recompositions. Without passing a SheetState explicitly, ModalBottomSheet creates one with default settings internally. Passing your own gives you the ability to read the sheet's current position and trigger programmatic transitions.

The most important option in rememberModalBottomSheetState is skipPartiallyExpanded. By default, when a user opens the sheet it can rest at a halfway-expanded state before they drag it fully open. Setting skipPartiallyExpanded to true makes the sheet jump directly to the fully expanded position on open, which is what most apps want for menus and action sheets.

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SheetStateExample() {
    val sheetState = rememberModalBottomSheetState(
        skipPartiallyExpanded = true
    )
    var showSheet by remember { mutableStateOf(false) }

    if (showSheet) {
        ModalBottomSheet(
            onDismissRequest = { showSheet = false },
            sheetState = sheetState
        ) {
            Column(modifier = Modifier.padding(24.dp)) {
                Text("Current position: ${sheetState.currentValue}")
                Text("Is visible: ${sheetState.isVisible}")
            }
        }
    }
}

The currentValue property returns the active SheetValue enum — either Hidden, PartiallyExpanded, or Expanded. The isVisible property is a convenient boolean that returns true any time the sheet is not hidden. You will use isVisible constantly when coordinating the Compose sheet state with other parts of your composable hierarchy.

Showing and Hiding the Sheet with Coroutines

Gesture-based dismissal works automatically, but sometimes you need to control the Compose bottom sheet from code. For example, you might want to close the sheet after a network call completes, or open it when the user taps a notification action. The SheetState exposes two suspend functions — show() and hide() — which must be called from a coroutine scope, because they run an animation.

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProgrammaticControlDemo() {
    val sheetState = rememberModalBottomSheetState()
    val scope = rememberCoroutineScope()
    var showSheet by remember { mutableStateOf(false) }

    Column(modifier = Modifier.padding(16.dp)) {
        Button(onClick = { showSheet = true }) {
            Text("Open Sheet")
        }
    }

    if (showSheet) {
        ModalBottomSheet(
            onDismissRequest = { showSheet = false },
            sheetState = sheetState
        ) {
            Column(modifier = Modifier.padding(24.dp)) {
                Text("This sheet can be dismissed using a button too.")
                Spacer(modifier = Modifier.height(16.dp))
                Button(onClick = {
                    scope.launch {
                        sheetState.hide()
                    }.invokeOnCompletion {
                        if (!sheetState.isVisible) {
                            showSheet = false
                        }
                    }
                }) {
                    Text("Close Programmatically")
                }
            }
        }
    }
}

The invokeOnCompletion callback is the key detail here. Calling hide() starts an exit animation that moves the sheet off screen, but it does not automatically update your showSheet boolean. You need to set that to false yourself once the animation finishes — that is what invokeOnCompletion handles. If you set showSheet to false before the animation completes, the sheet is immediately removed from the composition and the exit animation never plays, leaving the user with a jarring snap instead of a smooth slide.

Customizing ModalBottomSheet Appearance in Material 3

The default Jetpack Compose ModalBottomSheet looks clean and follows Material Design guidelines, but Material 3 gives you full control over its visual style. The containerColor, contentColor, shape, and dragHandle parameters are your main tools for matching the sheet to your app's design language.

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StyledSheetDemo() {
    var showSheet by remember { mutableStateOf(false) }
    val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)

    if (showSheet) {
        ModalBottomSheet(
            onDismissRequest = { showSheet = false },
            sheetState = sheetState,
            containerColor = Color(0xFF1A1A2E),
            contentColor = Color.White,
            shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
            dragHandle = null
        ) {
            Column(modifier = Modifier.padding(32.dp)) {
                Text(
                    text = "Dark Themed Sheet",
                    fontSize = 20.sp,
                    fontWeight = FontWeight.Bold,
                    color = Color.White
                )
                Spacer(modifier = Modifier.height(8.dp))
                Text(
                    text = "No drag handle, custom rounded corners, dark background.",
                    color = Color(0xFFAAAAAA),
                    fontSize = 14.sp
                )
            }
        }
    }
}

Setting dragHandle to null removes the gray pill-shaped handle from the top of the sheet. The sheet can still be dismissed by swiping or tapping the scrim — the handle is purely decorative. The shape parameter only affects the top-left and top-right corners, since the sheet always extends to the bottom edge of the screen. Using RoundedCornerShape with topStart and topEnd is therefore the natural and correct choice for an Android bottom sheet.

Adding a Scrollable List Inside the Compose Bottom Sheet

One of the most common patterns with Jetpack Compose ModalBottomSheet is placing a scrollable list of options inside it — think of the share sheet in Android, the overflow menu in a file manager, or the sort options in a shopping app. Since the content area accepts any composable, a LazyColumn fits naturally inside the sheet.

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp

data class MenuOption(val title: String, val icon: ImageVector)

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ListInsideSheetDemo() {
    val sheetState = rememberModalBottomSheetState()
    var showSheet by remember { mutableStateOf(false) }

    val options = listOf(
        MenuOption("Rename File", Icons.Default.Edit),
        MenuOption("Share File", Icons.Default.Share),
        MenuOption("Remove File", Icons.Default.Delete)
    )

    if (showSheet) {
        ModalBottomSheet(
            onDismissRequest = { showSheet = false },
            sheetState = sheetState
        ) {
            LazyColumn(modifier = Modifier.padding(bottom = 16.dp)) {
                items(options) { option ->
                    Row(
                        modifier = Modifier
                            .fillMaxWidth()
                            .clickable { /* handle action */ }
                            .padding(horizontal = 20.dp, vertical = 18.dp),
                        verticalAlignment = Alignment.CenterVertically
                    ) {
                        Icon(imageVector = option.icon, contentDescription = option.title)
                        Spacer(modifier = Modifier.width(16.dp))
                        Text(option.title, fontSize = 16.sp)
                    }
                    HorizontalDivider()
                }
            }
        }
    }
}

The LazyColumn inside the sheet scrolls its items when the content height exceeds the sheet's visible area. Compose handles the gesture conflict between the sheet's drag and the list's scroll intelligently — the user can scroll the list freely, and the sheet only starts sliding when the list has reached the very top and the user continues pulling downward.

BottomSheetScaffold vs ModalBottomSheet in Compose

Compose's Material 3 component library offers two bottom sheet composables, and choosing the right one matters for your app's user experience.

ModalBottomSheet is designed for temporary, action-triggered sheets. It floats over the entire screen including the navigation bar, displays a scrim that dims the content behind it, and blocks all interaction until dismissed. This is the right choice for share menus, option pickers, short input forms, and any sheet triggered by a discrete user action.

BottomSheetScaffold is designed for persistent, always-present panels. It is part of a scaffold layout where the sheet peeks from the bottom while the rest of the screen stays fully interactive. Use it for a mini music player that stays visible while the user browses, a map detail card that stays pinned to the bottom, or a filter panel that coexists with the main content.

The decision rule is simple: if the sheet appears because the user tapped something and disappears when the task is done, use Jetpack Compose ModalBottomSheet. If the sheet is always partially on screen and the user can expand or collapse it at will, use BottomSheetScaffold.

Full Working Example: ModalBottomSheet with File Actions

Here is a complete, self-contained example that brings together everything covered in this guide — SheetState configuration using rememberModalBottomSheetState, programmatic animated dismissal via coroutines, customized sheet styling, and a tappable action list that updates the main screen on selection.

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch

data class FileAction(val label: String, val icon: ImageVector)

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FileActionsSheet() {
    val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
    val scope = rememberCoroutineScope()
    var showSheet by remember { mutableStateOf(false) }
    var lastAction by remember { mutableStateOf("None selected") }

    val actions = listOf(
        FileAction("Edit File", Icons.Default.Edit),
        FileAction("Share File", Icons.Default.Share),
        FileAction("Delete File", Icons.Default.Delete)
    )

    Surface(
        modifier = Modifier.fillMaxSize(),
        color = MaterialTheme.colorScheme.background
    ) {
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(24.dp)
        ) {
            Text(
                text = "My Documents",
                fontSize = 24.sp,
                fontWeight = FontWeight.Bold
            )
            Spacer(modifier = Modifier.height(8.dp))
            Text(
                text = "Last action: $lastAction",
                color = Color.Gray,
                fontSize = 14.sp
            )
            Spacer(modifier = Modifier.height(24.dp))
            Button(onClick = { showSheet = true }) {
                Text("Open File Options")
            }
        }
    }

    if (showSheet) {
        ModalBottomSheet(
            onDismissRequest = { showSheet = false },
            sheetState = sheetState,
            shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
            containerColor = Color(0xFFF5F5F5)
        ) {
            Column(modifier = Modifier.padding(bottom = 40.dp)) {
                Text(
                    text = "File Options",
                    fontSize = 18.sp,
                    fontWeight = FontWeight.SemiBold,
                    modifier = Modifier.padding(horizontal = 24.dp, vertical = 20.dp)
                )
                HorizontalDivider()
                LazyColumn {
                    items(actions) { action ->
                        Row(
                            modifier = Modifier
                                .fillMaxWidth()
                                .clickable {
                                    lastAction = action.label
                                    scope.launch {
                                        sheetState.hide()
                                    }.invokeOnCompletion {
                                        if (!sheetState.isVisible) {
                                            showSheet = false
                                        }
                                    }
                                }
                                .padding(horizontal = 24.dp, vertical = 20.dp),
                            verticalAlignment = Alignment.CenterVertically
                        ) {
                            Icon(
                                imageVector = action.icon,
                                contentDescription = action.label,
                                tint = Color(0xFF6650A4)
                            )
                            Spacer(modifier = Modifier.width(16.dp))
                            Text(
                                text = action.label,
                                fontSize = 16.sp,
                                color = Color(0xFF1C1B1F)
                            )
                        }
                        HorizontalDivider(color = Color(0xFFE0E0E0))
                    }
                }
            }
        }
    }
}

Each tap on a file action updates the text on the main screen, then kicks off a coroutine that calls hide() to animate the sheet downward. Once the exit animation finishes, invokeOnCompletion sets showSheet to false, removing the Jetpack Compose ModalBottomSheet composable from the tree entirely. This pattern — animate first, update state after — is the correct technique for all programmatic dismissals in a Compose bottom sheet, and it guarantees a smooth exit every single time.