Newsletter
Subscribe if you want to know how to build digital products
What do you do in IT?
By submitting your email you agree to receive the content requested and to LeanCode's Privacy Policy

Mastering Edge-To-Edge in Flutter: A Deep Dive Into the System Navigation Bar in Android

Albert Wolszon - Mobile Flutter Developer at LeanCode
Albert Wolszon - Senior Flutter Developer at LeanCode, Flutter & Dart GDE
Jul 14, 2025 • 15 min
Flutter mobile development by LeanCode
Albert Wolszon - Mobile Flutter Developer at LeanCode
Albert Wolszon
Senior Flutter Developer at LeanCode, Flutter & Dart GDE

Rating: 5.00 / 5 Based on 9 reviews

Flutter mobile development by LeanCode

Rating: 5.00 / 5 Based on 9 reviews

Starting with Android 15, applications by default use edge-to-edge mode that renders system status and navigation elements over the application. In Android 15, we still have an option to opt out of this mode with android:windowOptOutEdgeToEdgeEnforcement. This won’t be the case for Android 16 and later.

The edge-to-edge display mode will be the new standard in the Android world, as it already is in the iOS world, and even now, it gives the apps a more modern, sleek, and native look.

Supporting the edge-to-edge for gesture navigation (the small bottom notch, like on iOS) is one challenge, but ensuring the app remains fully usable and visually appealing with 2- and 3-button navigation on older Android versions presents another.

In this article, I’ll guide you through the differences in how the system navigation bar appears and is configurable in Flutter in different Android versions, how to give the users the best (and the prettiest) experience, and how Flutter allows us to set these styles with some more low-level dives.

We will make our application go from this:

System Navigation bars Flutter in Android

To this:

System Navigation bars Flutter in Android

NOTE: Here you can download the above graphics and view them in better quality (source LeanCode).

Disclaimer: The contents of this article were researched on various devices from various brands and system overlays, but we focus primarily on a stock Android, i.e., a Google Pixel device, when referring to some data with no specifics, e.g., when gesture navigation was introduced.

System navigation types

System navigation types in Flutter

Until Android 10, there was only a 3-button navigation available. Three buttons on a black bar at the bottom of the screen. With Android 10, gesture navigation and the edge-to-edge UI display mode were introduced. With some of the updates, the bottom navigation evolved in appearance.

With edge-to-edge UI disabled in an app, the system navigation bar is opaque (usually black) and is below the app, not on top of it.

For the most modern feel of the app, I recommend always enabling edge-to-edge UI.

// In your main file or app widget. Calling it once is enough. SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);

Android and Flutter documentation can be sometimes not very clear (or even straight on misleading) so let me emphasize it here: if your application targets Android 15 (targetSdkVersion is 35, or you use one from flutter.targetSdkVersion and use Flutter 3.32), then the edge-to-edge is enabled by default only if the user’s device is running Android 15 or above. For versions lower than that, you need to enable this mode manually in the code.

The above snippet won’t have any effect on Android 9 and below, as there was no edge-to-edge or gesture navigation available then.

Once the edge-to-edge is enabled, we can set the system navigation bar style.

Setting the system navigation bar style in Flutter

The bottom navigation bar style can be altered with the SystemUiOverlayStyle class in Flutter.

This class holds properties related to the status bar (the bar at the top of the screen, with time, mobile reception, and notification icons): statusBarBrightness, statusBarColor, statusBarIconBrightness, systemStatusBarContrastEnforced, and the system navigation bar: systemNavigationBarColor, systemNavigationBarContrastEnforced, systemNavigationBarDividerColor, and systemNavigationBarIconBrightness.

Choosing the correct status bar values is much easier than the bottom navigation bar. There’s much less variation between Android versions there, and the status bar almost always is on top of the app bar anyway, so you have control over the color of the background, or you add some semi-transparent overlay, because you want the app bar’s title to also have a proper contrast with app bar’s background, if it would be some user-generated image content.

For the status bar, it’s enough to use one of two constants: SystemUiOverlayStyle.light and SystemUiOverlayStyle.dark. For the system navigation bar, it’s common that it appears over non-uniform backgrounds, and we need to tweak Flutter’s default to have nice edge-to-edge transparency.

Setting this style can be done on a few layers. Let me break them down for you.

Imperative call

The most direct way is to call SystemChrome.setSystemUIOverlayStyle static method. This imperative call is also used by the solutions mentioned below. Using this method directly is not a good idea. We usually need different styles on different screens, and Flutter calls this method every frame (how and why below), so it might override what we have set.

If you use MaterialApp or CupertinoApp, this method is called in the builder depending on the ThemeData.brightness and CupertinoThemeData.brightness, respectively (source and source).

AnnotatedRegion widget

An AnnotatedRegion class is a widget in the Flutter framework that allows us to annotate some part of the screen (with an annotated layer in the layer tree, specifically).

Every frame, “Flutter will hit-test the layer tree at the top and bottom of the screen (specifically: the center of the top and bottom unsafe rectangles) on each frame looking for an AnnotatedRegionLayer with an instance of a SystemUiOverlayStyle. The hit-test result from the top of the screen provides the status bar settings and the hit-test result from the bottom of the screen provides the system nav bar settings. If there is no AnnotatedRegionLayer on the bottom, the hit-test result from the top provides the system nav bar settings. If there is no AnnotatedRegionLayer on the top, the hit-test result from the bottom provides the system status bar settings.” (source from Flutter source code, applying styles source).

On a sidenote, the above behavior can be turned off by setting automaticSystemUiAdjustment to false.

The aforementioned strategy is used by Flutter to provide some defaults in Material’s AppBar and in Cupertino’s showCupertinoSheet and CupertinoNavigationBar (source, source, and source).

Styles to the Material AppBar can be set globally in AppBarTheme.systemOverlayStyle. But as written previously, the system navigation bar may need other tweaking for different screens than the status bar. As we already know, the annotated layers are hit-tested every frame, at the top and the bottom. If we set the AppBar.systemOverlayStyle we still can wrap our whole Scaffold (or any widget for the whole page, or just the bottom portion, Scaffold.body, etc.) with another AnnotatedRegion that will set the system bottom navigation bar style.

Flutter defaults

Flutter uses both strategies. What are the defaults, then, if we didn’t alter the styles ourselves?

MaterialApp and CupertinoApp set the style to SystemUiOverlayStyle.light or .dark, depending on the theme’s brightness. These two constants differ in status bar brightness, but the system navigation bar styles are the same: systemNavigationBarColor is black, and systemNavigationBarIconBrightness is light.

System Navigation bar Flutter

These defaults make the system navigation bar opaque (except for Android 15 and up, where it always is at least semi-transparent), which is the opposite of what we want to achieve.

If the user is using the gesture navigation, then having a correct and pretty effect is very easy, we just make the system navigation bar background transparent, and the notch will automatically change color depending on the background on which it appears to have a proper contrast, like on iOS. If the user is using the 2- or 3-button navigation, then it gets trickier.

How to achieve it then?

System navigation bar styles effects

I’ve researched how particular fields of the SystemUiOverlayStyle have an effect on the appearance of the system navigation bar on different Android devices. The tests assumed targeting Android 15 (API 35), Flutter 3.32.4 (latest as of writing this article). I skipped the navigation bar divider color, as it’s deprecated in Android now and not vital for the user’s experience. The table below is 100% accurate for Pixel devices on clean Android. Testing on a few other devices of different brands showed the same or very similar results. All tests with edge-to-edge enabled (except if not available).

One exception was a Xiaomi device on Android 10, which, with icon brightness light, always made the bottom navbar background color transparent (and icons light).

System navigation bar styles effects by LeanCode

NOTE: Here you can download the above table and view it in better quality (source LeanCode).

Above is a pretty complex table that explains how the SystemUiOverlayStyle fields control how the system navigation bar looks depending on the Android version. Below is my recommended approach and the tl;dr of the whole article, basically.

What values to use

I see two types of pages in the application that should be addressed differently: pages with static content at the bottom of the screen and those without it.

I see two types of pages in the application that should be addressed differently. Depending on what appears at the bottom edge of the page:

  • Pages with a solid (or close to solid) background. For example, a non-scrollable page with a solid background, a non-scrollable page, but with a known-in-advance image background that is very dark or very light at the bottom, pages with a bottom navigation bar (that has opaque background), pages with a sticky footer where the background of this footer have a frosted glass effect (heavy blur).
  • Pages with non-solid background. For example, pages with scrollable content with no UI stuck to the bottom, pages with some user-generated content background, where we don’t know the brightness of the bottom part of the image.

#1 Pages with a solid background at the bottom

If a page in the app has a solid background at the bottom, like a sticky footer with a button to submit a form, a bottom navigation bar (Flutter one, not OS one), bottom app bar, or if the page is “full screen”, i.e. it does not scroll and would never have content below the system navigation bar - the system navigation bar should have no background color and have a brightness that would contrast with the page’s background.

SystemUiOverlayStyle(
  systemNavigationBarColor: Colors.transparent,
  systemNavigationBarIconBrightness: switch (themeBackgroundBrightness) {
    Brightness.light => Brightness.dark,
    Brightness.dark => Brightnesss.light,
  },
  systemNavigationBarContrastEnforced: false,
);

The systemNavigationBarIconBrightness should be of the opposite brightness to the background of the page (or the bottom area of the page). If the background color of the page comes from your theme, the passed value should just be the opposite of your theme’s brightness. If it’s something else, like a static asset, it should be set to a brightness that would contrast with it, e.g., if the background image is very dark at the bottom, the brightness passed should be light. This only applies to Android 14 (API 34) and below.

On Android 15 (API 35) icon color will slightly vary depending on whether the phone is in dark theme, if the app’s theme (its colors and appearance in general) and the phone’s dark theme settings won’t match, the contrast between the icons and the background will be lower, like in the table below.

Dark & light mode on Android in Flutter

#2 Pages with a non-solid background at the bottom

If a page has no static/sticky element at the bottom of the screen, it’s better to use the system navigation bar with some background, so that it will always contrast with whatever it’s on top of. For this, we would need to know what the brightness of the background we draw on is, so that the system navigation bar background matches the theme of our app.

final themeBackgroundBrightness = ...;

SystemUiOverlayStyle(
  systemNavigationBarColor: Colors.transparent,
  systemNavigationBarIconBrightness: switch (themeBackgroundBrightness) {
    Brightness.light => Brightness.dark,
    Brightness.dark => Brightnesss.light,
  },
  systemNavigationBarContrastEnforced: true,
);

The themeBackgroundBrightness in the above examples is just the brightness of your theme. It would be Theme.brightnessOf(context) if using Material.

Dark & light mode on Android in Flutter

How to apply these in practice

Wrap your scrollable Scaffolds in an AnnotatedRegion with the style for #2 Pages with a non-solid background at the bottom. Wrap your BottomAppBars, BottomNavigationBars, sticky buttons, or other widgets that you have at the bottom of the screen (including the bottom unsafe area) with an AnnotatedRegion for #1 Pages with a solid background at the bottom.

If you have some kind of design system, or a few common widgets that wrap other widgets from the Flutter framework, it’s good to wrap the widgets mentioned above to accommodate these annotations.

Below are a few fragments of the code from one of our production apps:

class DSScaffold extends StatefulWidget {
  final Widget? stickyButton;
  final ImageProvider? backgroundImage;

  @override
  Widget build(BuildContext context) {
    return NotificationListener<ScrollMetricsNotification>(
      child: AnnotatedRegion(
        value: context.colorTheme.bottomSystemUiOverlayStyleOverScrolled,
        // ...

          bottomNavigationBar: switch (widget.stickyButton) {
            final stickyButton? => DSStickyButton(
              // ...
              backgroundBrightness: backgroundImage != null
                  // Our background images are always dark
                  ? Brightness.dark
                  : null,
              button: stickyButton,
            ),
          },
}

@internal
class DSStickyButton extends StatefulWidget {
  // ...

  @override
  Widget build(BuildContext context) {
    return NotificationListener<SizeChangedLayoutNotification>(
      child: AnnotatedRegion(
        value: context.colorTheme.bottomSystemUiOverlayStyleOnBackground
            .copyWith(
              systemNavigationBarIconBrightness:
                  switch (widget.backgroundBrightness) {
                    Brightness.dark => Brightness.light,
                    Brightness.light => Brightness.dark,
                    _ => null,
                  },
            ),
            // ...
  }
}

class DSBottomNavigationBar<T> extends StatelessWidget {
  // ...

  @override
  Widget build(BuildContext context) {
    return AnnotatedRegion(
      value: context.colorTheme.bottomSystemUiOverlayStyleOnBackground,
      // ...
  }
}

And the context.colorTheme fragment:

class DSColorTheme {
  // ...

  /// System UI overlay style for system navigation bar when it's on top of
  /// some more-or-less static background, not other content while e.g. scrolling.
  SystemUiOverlayStyle get bottomSystemUiOverlayStyleOnBackground {
    return SystemUiOverlayStyle(
      systemNavigationBarColor: transparent,
      systemNavigationBarIconBrightness: switch (brightness) {
        Brightness.light => Brightness.dark,
        Brightness.dark => Brightness.light,
      },
      systemNavigationBarContrastEnforced: false,
    );
  }

  /// System UI overlay style for system navigation bar when it's on top of
  /// some dynamic contents, like scrolling.
  SystemUiOverlayStyle get bottomSystemUiOverlayStyleOverScrolled {
    return SystemUiOverlayStyle(
      systemNavigationBarColor: transparent,
      systemNavigationBarIconBrightness: switch (brightness) {
        Brightness.light => Brightness.dark,
        Brightness.dark => Brightness.light,
      },
      systemNavigationBarContrastEnforced: true,
    );
  }
}

All above recommendations were applied to one of our apps, which screenshots of you can see in the intro of this article.

Additional remarks

After migrating to Flutter 3.32 from 3.24 in one of our production apps, WindowCompat.setDecorFitsSystemWindows(getWindow(), false) was causing issues with the native Android theme, causing a native app bar to appear.

SystemChrome.latestStyle is not reliable. It “resets” and is not a good source of current system navigation bar style-related data. But it’s marked as visible only for testing anyway, so you shouldn’t look at it.

Android API 35

The system navigation bar is on top of the app. It shows what’s below with semitransparency. Status bar color is always semitransparent (80%). If the color has 0 transparency, it’s changed to white. Icon brightness and status bar color have an effect only when contrast enforced is true.

Between OS’s dark and light themes, the icon color changes slightly, and if the app theme doesn’t match the OS theme, the contrast is smaller. It’s the case in other apps too (YouTube, for example).

Android API 34, 33, 31, 30, 29

Non-edge-to-edge (E2E) by default.

If not E2E, the system navigation bar is below the app. There is always a white background below the system navigation bar (in dark and light mode). Opaque and semitransparent colors work. Contrast enforced has no effect without E2E.

If E2E (by calling SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge)) - Same as API 35, but opaque system navigation bar colors are also in effect.

Icon colors don’t change between light and dark themes within the same icon brightness (as opposed to API 35). Icon brightness can be set if the contrast enforced is false.

Android API 28, 26

Just like above, but there is no edge-to-edge mode; calling SystemChrome.setEnabledSystemUiMode(SystemUiMode.edgeToEdge) does nothing.

Android API 24

Nothing is available, the system navigation bar is black with light icons, below the app.

Sources and further reading

Rate this article
Star 1Star 2Star 3Star 4Star 5
5.00 / 5 Based on 9 reviews

Read more

No Macros in Dart, How to Replace Freezed?

Unfortunately, the Dart team has taken up the difficult decision of abandoning their work on the macros language feature. Although freezed is a powerful package, it comes with some costs. At some point, we decided to move forward and use alternative solutions for new code and projects. See our approach.
A replacement for freezed in Dart

Lint Smarter, Not Harder: Migrate to the New Dart Analyzer Plugin System

The new first-party Dart analyzer plugin system is a significant step forward for Flutter developers, expected to replace custom_lint by integrating custom rules directly into the standard `dart analyze` command. See how to migrate to the new Dart Analyzer Plugin - the LeanCode way.
Migrate to the new Dart Analyzer Plugin System - LeanCode

How We Boosted Moving Flutter Widgets to Widgetbook

At LeanCode, while working on complex Flutter projects, we noticed that integrating widgets into Widgetbook was often repetitive and time-consuming. To address this, we created the Widgetbook Entries Generator - a VSCode extension that simplifies the process. See how it works.
Widgetbook Entries Generator by Leancode