← All posts

7 min read

Bottom Sheets, Dialogs and Overlays in Flutter: A Practical Guide

Bottom sheets, dialogs and overlays in Flutter: scroll-controlled sheets, adaptive dialogs, returning results, SnackBars, OverlayPortal and keyboard insets.

  • Flutter
  • Dialogs
  • UI
Cover illustration for Bottom Sheets, Dialogs and Overlays in Flutter: A Practical Guide

A bottom sheet looks like the easiest widget in Flutter. You call one function, pass a builder, and something slides up. Then a tester opens it on a small phone, taps the text field inside, and the keyboard covers the Save button. Or the sheet stops at half height and the list inside won’t scroll.

Almost every app I’ve worked on has a handful of sheets, dialogs and popups, and they cause a surprising share of UI bug reports. This post is a walkthrough of how I build each one so it behaves on real devices, and how I decide which one a screen actually needs.

Bottom sheets that size themselves properly

By default, showModalBottomSheet caps the sheet at roughly half the screen height. That’s fine for a short menu of actions. For anything taller, like a form or a filter panel, you need isScrollControlled: true, which lets the sheet take the height its content needs, up to the full screen.

Future<void> openFilters(BuildContext context) {
  return showModalBottomSheet<void>(
    context: context,
    isScrollControlled: true,
    useSafeArea: true,
    showDragHandle: true,
    builder: (context) => const FilterSheet(),
  );
}

Two other flags I set almost every time:

  • useSafeArea: true keeps a tall sheet from sliding under the status bar or a notch. Without it, a full-height sheet can put its top row where the user can’t tap it.
  • showDragHandle: true adds the small grab bar from Material 3. Users recognize it, and it tells them the sheet can be dismissed by dragging.

If the content is short, wrap it in a Column with mainAxisSize: MainAxisSize.min so the sheet hugs its children instead of stretching.

Long content: DraggableScrollableSheet

For a sheet that holds a long list, such as picking a country or browsing comments, I use DraggableScrollableSheet. It starts at one height and can be dragged up to another, and its scroll controller links the drag gesture with the list’s scrolling.

showModalBottomSheet<void>(
  context: context,
  isScrollControlled: true,
  useSafeArea: true,
  builder: (context) => DraggableScrollableSheet(
    expand: false,
    initialChildSize: 0.5,
    minChildSize: 0.3,
    maxChildSize: 0.95,
    builder: (context, scrollController) => ListView.builder(
      controller: scrollController,
      itemCount: countries.length,
      itemBuilder: (context, index) => ListTile(
        title: Text(countries[index].name),
        onTap: () => Navigator.pop(context, countries[index]),
      ),
    ),
  ),
);

The part people miss is passing scrollController to the list. If you create your own controller or leave it out, the list scrolls but the sheet never grows, or the sheet drags but the list is stuck. Also set expand: false inside a modal sheet. Otherwise the sheet tries to fill all available space and the size values don’t behave the way you expect.

Keyboard insets inside sheets

This is the bug I mentioned at the top. A modal sheet doesn’t move out of the keyboard’s way on its own. You have to add padding equal to the keyboard height:

class RenameSheet extends StatelessWidget {
  const RenameSheet({super.key});

  @override
  Widget build(BuildContext context) {
    final keyboard = MediaQuery.viewInsetsOf(context).bottom;

    return Padding(
      padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + keyboard),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          const TextField(autofocus: true),
          const SizedBox(height: 12),
          FilledButton(
            onPressed: () => Navigator.pop(context, 'new name'),
            child: const Text('Save'),
          ),
        ],
      ),
    );
  }
}

This only works with isScrollControlled: true, because the sheet needs permission to grow taller as the padding increases. If the form is long enough that it might not fit above the keyboard, wrap it in a SingleChildScrollView as well. I go into more detail on keyboard handling and validation in better forms in Flutter.

Dialogs, including adaptive ones

For confirmations, showDialog with an AlertDialog is still the right tool. Keep the title short, put the consequence in the content, and label buttons with verbs. “Delete” and “Cancel” beat “OK” and “Cancel”, because the user doesn’t have to reread the question to know what OK means.

If your app targets both platforms and you want dialogs that look native on each, use showAdaptiveDialog with AlertDialog.adaptive. On iOS and macOS it renders a Cupertino-style alert. Everywhere else it renders Material.

Future<bool> confirmDelete(BuildContext context) async {
  final confirmed = await showAdaptiveDialog<bool>(
    context: context,
    builder: (context) => AlertDialog.adaptive(
      title: const Text('Delete this invoice?'),
      content: const Text('This can\'t be undone.'),
      actions: [
        TextButton(
          onPressed: () => Navigator.pop(context, false),
          child: const Text('Cancel'),
        ),
        TextButton(
          onPressed: () => Navigator.pop(context, true),
          child: const Text('Delete'),
        ),
      ],
    ),
  );
  return confirmed ?? false;
}

On iOS, plain TextButtons work as actions, but if you want the exact native look you can switch to CupertinoDialogAction when the platform is Apple. I usually don’t bother unless the client cares about that detail. The design systems and theming post covers how I keep dialog styles consistent across the app.

Returning results with Navigator.pop

Sheets and dialogs are routes, so they return values the same way pushed screens do: Navigator.pop(context, value) completes the Future that showModalBottomSheet or showDialog returned.

Two things to handle every time:

  1. The result can be null. The user can tap the barrier, swipe the sheet down, or press back. Treat null as “cancelled”, which is why confirmDelete above returns confirmed ?? false.
  2. Check context.mounted after the await. The user may have left the screen while the dialog was open. Using the old context after that throws or, worse, does something odd.
final country = await pickCountry(context);
if (country == null || !context.mounted) return;
context.read<CheckoutCubit>().setCountry(country);

Give the generic type explicitly, as in showDialog<bool>. Without it, the result is dynamic and the compiler won’t help you when someone changes what the sheet returns.

SnackBars belong to ScaffoldMessenger

SnackBars are for short, non-blocking feedback: “Saved”, “Item removed, Undo”. Show them through ScaffoldMessenger, not a specific Scaffold:

ScaffoldMessenger.of(context).showSnackBar(
  SnackBar(
    content: const Text('Item removed'),
    action: SnackBarAction(label: 'Undo', onPressed: restoreItem),
  ),
);

Because the messenger sits above your routes, a SnackBar shown right before Navigator.pop survives the transition and appears on the previous screen. That’s usually what you want after saving a form.

If you need to show messages from somewhere without a convenient context, such as a global error handler, give MaterialApp a scaffoldMessengerKey and call currentState?.showSnackBar on it. Use that sparingly, though. Most SnackBars should come from the screen that knows what just happened.

Custom overlays with OverlayPortal

Sometimes you need something that floats above the UI but isn’t a route: a custom tooltip, an autocomplete dropdown, an onboarding hint pointing at a button. For these I use OverlayPortal, which renders its overlay child into the nearest Overlay while keeping it in your widget tree, so it still inherits theme and providers.

Pair it with CompositedTransformTarget and CompositedTransformFollower to pin the overlay to a widget:

class HintButton extends StatefulWidget {
  const HintButton({super.key});

  @override
  State<HintButton> createState() => _HintButtonState();
}

class _HintButtonState extends State<HintButton> {
  final _controller = OverlayPortalController();
  final _link = LayerLink();

  @override
  Widget build(BuildContext context) {
    return CompositedTransformTarget(
      link: _link,
      child: OverlayPortal(
        controller: _controller,
        overlayChildBuilder: (context) => CompositedTransformFollower(
          link: _link,
          targetAnchor: Alignment.bottomLeft,
          child: const Align(
            alignment: Alignment.topLeft,
            child: Card(
              child: Padding(
                padding: EdgeInsets.all(12),
                child: Text('Tap here to add your first item'),
              ),
            ),
          ),
        ),
        child: IconButton(
          icon: const Icon(Icons.help_outline),
          onPressed: _controller.toggle,
        ),
      ),
    );
  }
}

Compared with inserting an OverlayEntry manually, there’s no entry to remember to remove, and the overlay goes away when the widget does. Before building your own dropdown, though, check whether MenuAnchor, DropdownMenu or Autocomplete already does the job. I compare those options in building dropdown lists in Flutter.

When a full screen beats a sheet

Sheets are great for quick, focused tasks. They’re a poor fit when:

  • The task has multiple steps. Navigation inside a sheet gets awkward fast, and back-button behavior becomes confusing.
  • There’s a long form. Keyboard, scrolling and drag-to-dismiss all fight each other. A full screen with a proper app bar is calmer.
  • Losing the input would hurt. A stray swipe can dismiss a sheet. If the user typed a paragraph, put it on a screen where leaving is deliberate.
  • The content should be linkable. You can’t easily deep link to a sheet, but you can to a route.

My rule of thumb: if the user will spend more than a few seconds in it or type more than a line, it’s a screen. You can still present that screen as a fullscreen dialog route if you want the slide-up feel without the sheet’s quirks.

Takeaways

  • Use isScrollControlled: true and useSafeArea: true for any sheet taller than a short menu.
  • In DraggableScrollableSheet, always pass the provided scrollController to your list and set expand: false in modals.
  • Pad sheets by MediaQuery.viewInsetsOf(context).bottom so the keyboard never hides the primary button.
  • Type your results, treat null as cancel, and check context.mounted after every await.
  • Show SnackBars through ScaffoldMessenger, and use OverlayPortal for custom floating UI.
  • When a task needs steps, long input or a deep link, build a screen instead of a sheet.

Small surfaces, big impact: get these right and a lot of “it feels off” feedback simply never shows up.

Comments

Questions, corrections or your own experience — leave a comment below (GitHub sign-in).