Flutter: BottomSheet and showModalBottomSheet
10 min read

A bottom sheet is a panel that slides up from the bottom of the screen: the share options for a photo, the filters for a list, a short comment form. Because it sits close to the thumb, it's more comfortable on a phone than a dialog, and it doesn't hide the page's context completely. Flutter has two kinds: a modal sheet dims the page behind it, blocks interaction until it closes and can return a value; a persistent sheet behaves like part of the page, and interaction with the content underneath continues. In this post I cover both, the height and keyboard problems, the draggable sheet and the common mistakes.
Basic Usage with showModalBottomSheet
The most common kind is the modal sheet. showModalBottomSheet returns a Future; when the sheet closes with Navigator.pop(context, value), that value comes back:
import 'package:flutter/material.dart';
enum PhotoAction { share, copyLink, delete }
class PhotoPage extends StatefulWidget {
const PhotoPage({super.key});
@override
State<PhotoPage> createState() => _PhotoPageState();
}
class _PhotoPageState extends State<PhotoPage> {
Future<void> _openActions() async {
final action = await showModalBottomSheet<PhotoAction>(
context: context,
showDragHandle: true,
useSafeArea: true,
builder: (context) => Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.share_outlined),
title: const Text('Share'),
onTap: () => Navigator.pop(context, PhotoAction.share),
),
ListTile(
leading: const Icon(Icons.link),
title: const Text('Copy link'),
onTap: () => Navigator.pop(context, PhotoAction.copyLink),
),
ListTile(
leading: const Icon(Icons.delete_outline),
title: const Text('Delete'),
onTap: () => Navigator.pop(context, PhotoAction.delete),
),
const SizedBox(height: 8),
],
),
);
if (!mounted || action == null) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Selected: ${action.name}')),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Photo'),
actions: [
IconButton(onPressed: _openActions, icon: const Icon(Icons.more_vert)),
],
),
);
}
}Three details matter. If the user closes the sheet by swiping down or tapping the background, the Future returns null, which is why the action == null check is required. The mounted check after the await prevents using a stale context if the page was closed while the sheet was open. And mainAxisSize: MainAxisSize.min on the Column makes the sheet only as tall as its content; I explain why in the MainAxisSize post. Returning a value works the same way as with page navigation, because a modal sheet is a route too; details in the Navigator post.
In this example we returned the choice to the page and handled the result there instead of doing the work inside the sheet. There's a concrete reason: a SnackBar shown with the page's ScaffoldMessenger while the sheet is open is drawn in the page's Scaffold, so it ends up behind the sheet and its scrim. If the sheet only answers the question "what did the user choose?" and closes, tasks like showing a message, asking for delete confirmation or sending a request happen on the page, where they're visible. The sheet's code also gets simpler and can be reused on other screens as is.
Important Parameters
| Parameter | Default | What it does |
|---|---|---|
isScrollControlled |
false |
When true, the sheet can grow up to the full screen height |
useSafeArea |
false |
Keeps the sheet away from the status bar, notch and similar areas at the top |
showDragHandle |
from the theme, otherwise false |
Shows Material 3's small drag handle at the top |
isDismissible |
true |
Whether tapping the background closes it |
enableDrag |
true |
Whether it can be closed by swiping down |
backgroundColor, shape |
from the theme | Background color and corner shape |
constraints |
at most 640 pixels wide in Material 3 | The sheet's size limits |
useRootNavigator |
false |
With nested Navigators, opens the sheet on the topmost Navigator |
Most of the appearance-related ones (showDragHandle, backgroundColor, shape, constraints) can also be set at the theme level through BottomSheetThemeData; more on that below.
Height: isScrollControlled
By default a modal sheet grows to at most 9/16 of the screen height, however long its content is. For a short list of options that's enough. If the content is longer, the bottom gets cut off or overflows. For the sheet to grow up to the full screen, you need isScrollControlled: true. In that case passing useSafeArea: true is a good habit too; otherwise a tall sheet can reach up under the status bar.
isScrollControlled: true doesn't make the sheet full screen by itself; it grows only as much as the content needs. When you put long content inside a SingleChildScrollView or a list, the part that doesn't fit on screen becomes scrollable.
Forms and the Keyboard: viewInsets
If the sheet contains a text field, the field ends up behind the keyboard when it opens. The fix has two parts: allow the sheet to grow upward with isScrollControlled: true, and add space below the content equal to the keyboard height:
Future<String?> showCommentSheet(BuildContext context) {
return showModalBottomSheet<String>(
context: context,
isScrollControlled: true,
useSafeArea: true,
showDragHandle: true,
builder: (context) => Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
bottom: MediaQuery.viewInsetsOf(context).bottom + 16,
),
child: TextField(
autofocus: true,
textInputAction: TextInputAction.send,
decoration: const InputDecoration(labelText: 'Your comment'),
onSubmitted: (value) => Navigator.pop(context, value.trim()),
),
),
);
}MediaQuery.viewInsetsOf(context).bottom is the height of the keyboard currently covering the bottom of the screen; it's 0 when the keyboard is closed. Details about the text field itself (focus, keyboard type, onSubmitted) are in the TextField post.
DraggableScrollableSheet: A Draggable Sheet
If you want a sheet with a long list to open halfway first and grow as the user pulls it up, use DraggableScrollableSheet:
Future<void> showCountryPicker(BuildContext context, List<String> countries) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (context) => DraggableScrollableSheet(
expand: false,
initialChildSize: 0.5,
minChildSize: 0.3,
maxChildSize: 0.95,
snap: true,
snapSizes: const [0.5],
builder: (context, scrollController) => ListView.builder(
controller: scrollController,
itemCount: countries.length,
itemBuilder: (context, index) => ListTile(
title: Text(countries[index]),
onTap: () => Navigator.pop(context),
),
),
),
);
}Sizes are fractions of the total height available to the sheet: 0.5 means half of that area. snap: true and snapSizes make the sheet settle at specific heights when the user lets go. Two rules: always pass the scrollController from builder to the list, otherwise the list scrolls but the sheet doesn't grow; and use expand: false inside a modal sheet, otherwise the sheet tries to fill all the available space. For ListView.builder itself, see the ListView post.
Persistent Bottom Sheets
A persistent sheet doesn't dim what's behind it and doesn't block interaction with the page; think of the "now playing" bar in a music app. It's opened with Scaffold.of(context).showBottomSheet, which returns a controller for closing it:
class PlayerPage extends StatefulWidget {
const PlayerPage({super.key});
@override
State<PlayerPage> createState() => _PlayerPageState();
}
class _PlayerPageState extends State<PlayerPage> {
PersistentBottomSheetController? _sheet;
void _toggleSheet(BuildContext scaffoldContext) {
if (_sheet != null) {
_sheet!.close();
return;
}
_sheet = Scaffold.of(scaffoldContext).showBottomSheet(
(context) => const ListTile(
leading: Icon(Icons.music_note),
title: Text('Now playing'),
subtitle: Text('Track name'),
),
showDragHandle: true,
);
_sheet!.closed.then((_) {
if (mounted) setState(() => _sheet = null);
});
setState(() {});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Playlist')),
body: Builder(
builder: (scaffoldContext) => Center(
child: FilledButton(
onPressed: () => _toggleSheet(scaffoldContext),
child: Text(_sheet == null ? 'Show player' : 'Hide'),
),
),
),
);
}
}The Builder is there on purpose: Scaffold.of searches upward from a context for a Scaffold, and the build method's own context sits above the Scaffold. The closed Future also completes when the user swipes the sheet away, which is why we reset the state there. If the sheet should always be visible, passing a widget to the Scaffold's bottomSheet parameter is simpler; but showBottomSheet can't be called while that parameter is set.
A persistent sheet suits information the user keeps an eye on while continuing to work with the page: the summary of a selected place on a map, a music player, a cart summary. When you need a decision from the user, a modal sheet is the better fit, because a persistent sheet doesn't return a result; you can only find out that it closed through closed.
Appearance, Animation and Theme
Instead of writing showDragHandle: true on every call, you can move it into the theme: ThemeData(bottomSheetTheme: const BottomSheetThemeData(showDragHandle: true)). Background color, corner shape and width limit are set through the same class. I explain the general idea of component themes in the Theme and ThemeData post.
The drag handle isn't just a visual hint. For screen reader users it's announced as a tappable element labeled "dismiss" that closes the sheet; for users who can't perform the swipe-down gesture, that's an important affordance. For the same reason, having a visible close or cancel button inside the sheet is a good habit.
If you want to change the opening and closing speed, there's the sheetAnimationStyle parameter:
showModalBottomSheet<void>(
context: context,
sheetAnimationStyle: const AnimationStyle(
duration: Duration(milliseconds: 400),
reverseDuration: Duration(milliseconds: 250),
),
builder: (context) => const SizedBox(height: 200),
);To turn the animation off completely, pass AnimationStyle.noAnimation. The default durations are chosen according to the Material guidelines, so I'd only change them if you have a real reason.
Mini Scenario: A Filter Panel
Picture a product list: tapping the filter icon in the top right opens a panel with sorting, categories and an "in stock only" option; "Apply" returns the selection to the page, and if the panel is swiped away nothing changes. A badge on the icon shows how many filters are active:
enum SortOrder { newest, priceLow, priceHigh }
class ProductFilter {
const ProductFilter({
this.sort = SortOrder.newest,
this.categories = const {},
this.onlyInStock = false,
});
final SortOrder sort;
final Set<String> categories;
final bool onlyInStock;
ProductFilter copyWith({
SortOrder? sort,
Set<String>? categories,
bool? onlyInStock,
}) {
return ProductFilter(
sort: sort ?? this.sort,
categories: categories ?? this.categories,
onlyInStock: onlyInStock ?? this.onlyInStock,
);
}
}
class FilterSheet extends StatefulWidget {
const FilterSheet({super.key, required this.initial});
final ProductFilter initial;
@override
State<FilterSheet> createState() => _FilterSheetState();
}
class _FilterSheetState extends State<FilterSheet> {
static const _allCategories = ['Books', 'Electronics', 'Clothing', 'Toys'];
static const _sortLabels = {
SortOrder.newest: 'Newest',
SortOrder.priceLow: 'Price: low to high',
SortOrder.priceHigh: 'Price: high to low',
};
late ProductFilter _filter = widget.initial;
void _toggleCategory(String category, bool selected) {
final next = {..._filter.categories};
selected ? next.add(category) : next.remove(category);
setState(() => _filter = _filter.copyWith(categories: next));
}
@override
Widget build(BuildContext context) {
final text = Theme.of(context).textTheme;
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Text('Sort by', style: text.titleMedium),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
for (final entry in _sortLabels.entries)
ChoiceChip(
label: Text(entry.value),
selected: _filter.sort == entry.key,
onSelected: (_) =>
setState(() => _filter = _filter.copyWith(sort: entry.key)),
),
],
),
const SizedBox(height: 16),
Text('Categories', style: text.titleMedium),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
for (final category in _allCategories)
FilterChip(
label: Text(category),
selected: _filter.categories.contains(category),
onSelected: (selected) => _toggleCategory(category, selected),
),
],
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('In stock only'),
value: _filter.onlyInStock,
onChanged: (value) =>
setState(() => _filter = _filter.copyWith(onlyInStock: value)),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => setState(() => _filter = const ProductFilter()),
child: const Text('Reset'),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton(
onPressed: () => Navigator.pop(context, _filter),
child: const Text('Apply'),
),
),
],
),
],
),
);
}
}
class ProductsPage extends StatefulWidget {
const ProductsPage({super.key});
@override
State<ProductsPage> createState() => _ProductsPageState();
}
class _ProductsPageState extends State<ProductsPage> {
ProductFilter _filter = const ProductFilter();
Future<void> _openFilters() async {
final result = await showModalBottomSheet<ProductFilter>(
context: context,
isScrollControlled: true,
useSafeArea: true,
showDragHandle: true,
builder: (context) => FilterSheet(initial: _filter),
);
if (result == null) return; // Sheet dismissed, filter unchanged
setState(() => _filter = result);
}
@override
Widget build(BuildContext context) {
final count = _filter.categories.length + (_filter.onlyInStock ? 1 : 0);
return Scaffold(
appBar: AppBar(
title: const Text('Products'),
actions: [
IconButton(
onPressed: _openFilters,
tooltip: 'Filter',
icon: Badge(
isLabelVisible: count > 0,
label: Text('$count'),
child: const Icon(Icons.tune),
),
),
],
),
body: Center(
child: Text(
'Sort: ${_filter.sort.name}\n'
'Categories: ${_filter.categories.isEmpty ? 'all' : _filter.categories.join(', ')}\n'
'In stock only: ${_filter.onlyInStock ? 'yes' : 'no'}',
textAlign: TextAlign.center,
),
),
);
}
}The most important decision in this scenario is that the panel is its own StatefulWidget. Because the sheet opens as a separate route, the page's setState doesn't rebuild it; instant changes like chip selections live in the sheet's own state. The panel starts with a copy of the page's filter, and changes only come back when "Apply" is tapped; if the user changes their mind and swipes the panel away, result is null and the page's filter stays as it was. Because filter values travel as immutable objects with copyWith, "Reset" can go back to the defaults in one line. The count on the filter icon is shown with Material 3's built-in Badge widget.
When to Use a Bottom Sheet, and When Something Else
A bottom sheet is ideal for action lists with a few options, filters and short forms. For a confirmation the user must read and decide on ("Delete this?"), an AlertDialog is a better fit, because it focuses attention in the middle of the screen. For a short list of choices tied to a button, a PopupMenuButton is lighter. Long forms with several steps are easier to fill in on a separate page than squeezed into a sheet.
Common Mistakes
1. Long content getting cut off
Symptom: the sheet stops halfway up the screen, the buttons at the bottom aren't visible or an overflow warning appears. The default height limit is 9/16 of the screen. Pass isScrollControlled: true and make the content scrollable.
2. The keyboard covering the text field
Tapping a field in the sheet brings up a keyboard that covers it. Together with isScrollControlled: true, add space below the content equal to MediaQuery.viewInsetsOf(context).bottom.
3. DraggableScrollableSheet not growing
The list scrolls but the sheet doesn't grow: the scrollController from builder wasn't passed to the list. If the sheet fills the whole screen as soon as it opens, expand: false is missing.
4. Changes inside the sheet not showing up
Building the sheet's content from the page's state and calling setState on the page doesn't update the sheet, because the sheet is a separate route. Move the content into its own StatefulWidget, or use StatefulBuilder for small bits of state. StatefulBuilder gives its builder function its own setState; for small state like a single switch or counter it works without a separate class, but as the content grows a separate widget is much easier to read.
5. The Scaffold.of error
If you get this error when opening a persistent sheet:
Scaffold.of() called with a context that does not contain a Scaffold.you're using the context of the build method that creates the Scaffold. Put the button inside a Builder or extract it into a separate widget.
Frequently Asked Questions
How do I stop the user from closing the sheet by accident?
isDismissible: false prevents closing by tapping the background, and enableDrag: false prevents closing by swiping down. For Android's back button or back gesture, wrap the sheet's content in PopScope(canPop: false, ...); for a form that warns about unsaved changes, all three are used together. Don't forget to always leave the user a visible "Cancel" button.
Why does the sheet look narrow and centered on a tablet?
In Material 3 the default width limit of a modal sheet is 640 pixels; on wide screens the sheet is centered at that width. If you want it to span the full width, pass constraints: const BoxConstraints(maxWidth: double.infinity), or apply the same setting app-wide through BottomSheetThemeData.
Can the page underneath be tapped while the sheet is open?
Not with a modal sheet; the scrim behind it catches taps and closes the sheet by default. If interaction with the page underneath needs to continue, use a persistent sheet (showBottomSheet or Scaffold.bottomSheet).
Why does the bottom navigation bar stay on top when the sheet opens?
If your app uses a separate Navigator for each tab (nested navigation), showModalBottomSheet opens on the nearest Navigator by default, which means inside the tab; the bottom navigation bar stays outside the scrim. To open the sheet above everything, pass useRootNavigator: true. In that case the Navigator.pop call that closes the sheet must also use the sheet's own context; as long as you use the context passed to builder, you're fine.
Related Posts
Flutter: Card Widget and Design Usage
Flutter Card guide: Material 3 Card.filled and Card.outlined, clipBehavior with images, tappable cards with InkWell, common mistakes and a product card.
Flutter: AppBar Widget and Customization
A practical Flutter AppBar guide: title, leading, actions and bottom, the Material 3 scrolled-under color change, and fixes for common mistakes.
Flutter: AlertDialog Usage and Customization
Flutter AlertDialog for confirmations, input and adaptive dialogs, plus fixes for context.mounted, StatefulBuilder and overflow bugs.