Sitemap

Custom Bottom Sheets in Compose Without the Hassle

6 min readFeb 9, 2026

--

Let’s be honest, bottom sheets are everywhere in modern Android apps, from Google Maps to Spotify. They’re perfect for showing contextual actions, filters, or additional details without navigating to a new screen. But if you’ve tried implementing them in Jetpack Compose, you know the Material 3 ModalBottomSheet can feel limiting.

Today, I’ll show you how to build a flexible, custom bottom sheet that gives you full control over behavior and appearance.

Press enter or click to view image in full size

Why Custom?

The built-in ModalBottomSheet is great for simple cases, but you'll hit walls when you need:

  • Multiple drag handles or custom gestures
  • Non-standard peek heights
  • Complex animations or transitions
  • Integration with your existing state management
  • Custom scrim colors or blur effects
  • Nested scrolling behavior that doesn’t fight you

Let’s build something better.

Understanding Sheet State

Before we jump into code, let’s talk about how bottom sheets actually work in Compose. The SheetState manages three key things: the current value (collapsed, expanded, or hidden), the offset in pixels, and whether the sheet is currently animating.

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun rememberCustomSheetState(
initialValue: SheetValue = SheetValue.Hidden,
confirmValueChange: (SheetValue) -> Boolean = { true }
): SheetState {
return rememberModalBottomSheetState(
skipPartiallyExpanded = false,
confirmValueChange = confirmValueChange
)
}

The confirmValueChange callback is your gatekeeper. Use it to prevent the sheet from closing during critical operations like form submission or loading states.

The Basic Structure

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CustomBottomSheet(
onDismiss: () -> Unit,
sheetState: SheetState = rememberModalBottomSheetState(),
content: @Composable ColumnScope.() -> Unit
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
dragHandle = { BottomSheetDefaults.DragHandle() }
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
) {
content()
}
}
}

This gives you a foundation, but the real power comes from customization.

Controlling the Peek Height

Here’s where it gets interesting. To set a custom peek height, you need to work with the sheet state and understand how partially expanded states work.

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BottomSheetWithPeekHeight(
peekHeightDp: Dp = 200.dp,
onDismiss: () -> Unit,
content: @Composable ColumnScope.() -> Unit
) {
val density = LocalDensity.current
val peekHeightPx = with(density) { peekHeightDp.toPx() }

val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = false
)

ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
modifier = Modifier.heightIn(min = peekHeightDp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
content = content
)
}
}

If you need more precise control over when the sheet stops at the peek height, you can use the anchoredDraggable modifier directly, though this requires more setup:

@Composable
fun AdvancedPeekHeightSheet(
peekHeight: Dp,
fullHeight: Dp,
content: @Composable () -> Unit
) {
var offset by remember { mutableFloatStateOf(0f) }
val density = LocalDensity.current

Box(
modifier = Modifier
.fillMaxSize()
.offset { IntOffset(0, offset.roundToInt()) }
.draggable(
orientation = Orientation.Vertical,
state = rememberDraggableState { delta ->
offset = (offset + delta).coerceIn(
minimumValue = 0f,
maximumValue = with(density) { fullHeight.toPx() }
)
}
)
) {
content()
}
}

Adding Custom Drag Handles

We can’t even debate about the default drag handle being minimal, so let’s make it more visible and add some personality:

@Composable
fun CustomDragHandle(
modifier: Modifier = Modifier,
color: Color = MaterialTheme.colorScheme.onSurfaceVariant
) {
Box(
modifier = modifier
.fillMaxWidth()
.padding(vertical = 12.dp),
contentAlignment = Alignment.Center
) {
Surface(
modifier = Modifier
.width(48.dp)
.height(4.dp),
shape = RoundedCornerShape(2.dp),
color = color.copy(alpha = 0.4f)
) {}
}
}

Do you want something more interactive? Add haptic feedback:

@Composable
fun InteractiveDragHandle() {
val haptic = LocalHapticFeedback.current
var isPressed by remember { mutableStateOf(false) }

Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
isPressed = true
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
tryAwaitRelease()
isPressed = false
}
)
},
contentAlignment = Alignment.Center
) {
Surface(
modifier = Modifier
.width(if (isPressed) 56.dp else 48.dp)
.height(4.dp),
shape = RoundedCornerShape(2.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = if (isPressed) 0.6f else 0.4f
)
) {}
}
}

Animating Sheet Appearance

The default animation is nice, but sometimes you want more control. Here’s how to add a custom entrance animation:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AnimatedBottomSheet(
onDismiss: () -> Unit,
content: @Composable ColumnScope.() -> Unit
) {
val sheetState = rememberModalBottomSheetState()
var contentAlpha by remember { mutableFloatStateOf(0f) }

LaunchedEffect(sheetState.isVisible) {
if (sheetState.isVisible) {
animate(
initialValue = 0f,
targetValue = 1f,
animationSpec = tween(durationMillis = 300)
) { value, _ ->
contentAlpha = value
}
}
}

ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
) {
Column(
modifier = Modifier
.fillMaxWidth()
.alpha(contentAlpha)
.padding(16.dp),
content = content
)
}
}

Real-World Example: Filter Sheet

Let’s build something practical: a filter bottom sheet that you’d actually use in a real app:

data class FilterState(
val category: String? = null,
val priceRange: ClosedFloatingPointRange<Float> = 0f..1000f,
val sortBy: SortOption = SortOption.RELEVANCE,
val inStock: Boolean = false
)

enum class SortOption {
RELEVANCE, PRICE_LOW, PRICE_HIGH, NEWEST
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FilterBottomSheet(
currentFilters: FilterState,
categories: List<String>,
onApplyFilters: (FilterState) -> Unit,
onDismiss: () -> Unit
) {
var filters by remember { mutableStateOf(currentFilters) }
val sheetState = rememberModalBottomSheetState()

ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
dragHandle = { CustomDragHandle() }
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.verticalScroll(rememberScrollState())
) {
Text(
"Filters",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)

Spacer(modifier = Modifier.height(24.dp))

// Category Section
Text(
"Category",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(8.dp))

FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
categories.forEach { category ->
FilterChip(
selected = filters.category == category,
onClick = {
filters = filters.copy(
category = if (filters.category == category) null else category
)
},
label = { Text(category) }
)
}
}

Spacer(modifier = Modifier.height(24.dp))

// Price Range Section
Text(
"Price Range",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(8.dp))

RangeSlider(
value = filters.priceRange,
onValueChange = { filters = filters.copy(priceRange = it) },
valueRange = 0f..1000f,
steps = 19
)

Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
"$${filters.priceRange.start.toInt()}",
style = MaterialTheme.typography.bodyMedium
)
Text(
"$${filters.priceRange.endInclusive.toInt()}",
style = MaterialTheme.typography.bodyMedium
)
}

Spacer(modifier = Modifier.height(24.dp))

// Sort By Section
Text(
"Sort By",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(8.dp))

SortOption.entries.forEach { option ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { filters = filters.copy(sortBy = option) }
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = filters.sortBy == option,
onClick = { filters = filters.copy(sortBy = option) }
)
Spacer(modifier = Modifier.width(12.dp))
Text(
option.name.lowercase().replace('_', ' ')
.replaceFirstChar { it.uppercase() },
style = MaterialTheme.typography.bodyLarge
)
}
}

Spacer(modifier = Modifier.height(16.dp))

// In Stock Toggle
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"In Stock Only",
style = MaterialTheme.typography.bodyLarge
)
Switch(
checked = filters.inStock,
onCheckedChange = { filters = filters.copy(inStock = it) }
)
}

Spacer(modifier = Modifier.height(24.dp))

// Action Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedButton(
onClick = {
filters = FilterState()
},
modifier = Modifier.weight(1f)
) {
Text("Clear All")
}

Button(
onClick = {
onApplyFilters(filters)
onDismiss()
},
modifier = Modifier.weight(1f)
) {
Text("Apply Filters")
}
}

Spacer(modifier = Modifier.height(16.dp))
}
}
}

Handling Keyboard Interactions

Bottom sheets and keyboards don’t always play nice, I mean, you may end up typing into a textfield you can’t see. Here’s how to handle it properly:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun KeyboardAwareBottomSheet(
onDismiss: () -> Unit,
content: @Composable ColumnScope.() -> Unit
) {
val keyboardController = LocalSoftwareKeyboardController.current

ModalBottomSheet(
onDismissRequest = {
keyboardController?.hide()
onDismiss()
},
windowInsets = WindowInsets.ime
) {
Column(
modifier = Modifier
.fillMaxWidth()
.imePadding()
.padding(16.dp),
content = content
)
}
}

Preventing Accidental Dismissals

Sometimes you’re in the middle of a form or processing a payment, and you don’t want the user to accidentally dismiss the sheet:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProtectedBottomSheet(
isProcessing: Boolean,
onDismiss: () -> Unit,
content: @Composable ColumnScope.() -> Unit
) {
val sheetState = rememberModalBottomSheetState(
confirmValueChange = { !isProcessing }
)

BackHandler(enabled = isProcessing) {
// Prevent back button from dismissing during processing
}

ModalBottomSheet(
onDismissRequest = {
if (!isProcessing) onDismiss()
},
sheetState = sheetState
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
content = content
)
}
}

Custom Scrim Effects

Do you want to customize the background overlay? The `scrimColor` does that for you! Here’s how:

@Composable
fun BlurredScrimBottomSheet(
onDismiss: () -> Unit,
content: @Composable ColumnScope.() -> Unit
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
scrimColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
content = content
)
}
}

Testing Your Bottom Sheet

Don’t forget to test! Here’s a basic test setup:

@Test
fun bottomSheet_opensAndCloses() {
var isSheetVisible by mutableStateOf(false)

composeTestRule.setContent {
if (isSheetVisible) {
CustomBottomSheet(
onDismiss = { isSheetVisible = false }
) {
Text("Sheet Content")
}
}
}

isSheetVisible = true
composeTestRule.waitForIdle()

composeTestRule
.onNodeWithText("Sheet Content")
.assertIsDisplayed()
}

Okay Okay, Let’s Wrap It All Up

Custom bottom sheets give you the flexibility to match your design system while you also keep the smooth Material 3 animations. I’d recommend that you start out with the built-in components and customize only what you need to.

The key is understanding the sheet state and how to manipulate it. Once you’ve got that down, bottom sheets become one of the most versatile tools in your Jetpack Compose toolkit.

This time, we’re doing more than just a catchphrase; this is a prayer you need 😁

May your keyboard never clash with your sheet, and your dismiss gestures feel natural.

--

--

Christophy Barth
Christophy Barth

Written by Christophy Barth

Mobile Architect & Founder of Christophy LTD | 5+ Years Kotlin/Flutter | Creator of NexxEd & Gollie AI.