Guide
Responsive Flutter web: one app shell for phone, tablet and desktop
How to make one Flutter codebase feel native at 390 px and at 1440 px: breakpoints, navigation that changes shape with the window, list-detail screens that push on phones and split on desktop, and the MediaQuery-versus-LayoutBuilder trap. With the real code from Template369.
Updated
The short version
- Decide by window width, not device type. A phone in landscape, an iPad in split view and a narrow browser window all need the same answer.
- Use a few breakpoints, and name them. Template369 has two core ones: below 850 px is phone, 850 to 1249 px is tablet, and 1250 px and up is desktop. Its navigation shell adds three more (600, 1100 and 1350 px).
- Let the navigation change shape with the window. A bottom bar for the main sections on narrow windows, a rail or a full side menu on wide ones, all driven by one piece of state.
- List-detail screens push on phones and split on wider windows. The same detail widget renders in both, with or without its own app bar.
- Know whether you're measuring the window or the space you were given.
MediaQuery.sizeOfandLayoutBuilderanswer different questions.
1. Put the breakpoints in one widget
Template369 keeps them in a small ResponsiveBuilder. It has a builder for each size, plus static helpers for code that only needs a yes or no:
class ResponsiveBuilder extends StatelessWidget {
// mobileBuilder, tabletBuilder, desktopBuilder ...
static const tabletMin = 850.0;
static const desktopMin = 1250.0;
static bool isMobile(BuildContext context) =>
MediaQuery.sizeOf(context).width < tabletMin;
static bool isDesktop(BuildContext context) =>
MediaQuery.sizeOf(context).width >= desktopMin;
// Window 850-1099 px: used by settings below.
static bool isSmallTablet(BuildContext context) {
final width = MediaQuery.sizeOf(context).width;
return width >= tabletMin && width < 1100;
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth >= desktopMin) {
return desktopBuilder(context, constraints);
} else if (constraints.maxWidth >= tabletMin) {
return tabletBuilder(context, constraints);
} else {
return mobileBuilder(context, constraints);
}
},
);
}
} This is a tidied version: Template369's own file repeats 850 and 1250 as literals and uses MediaQuery.of(context).size. Named constants are the better habit, because the exact numbers matter less than using the same ones everywhere. Flutter's adaptive design guide points to Material's window size classes, which suggest a bottom navigation bar below 600 logical pixels and a navigation rail at 600 and above. Template369 departs from that: from 600 to 1249 px it shows the bottom bar and the rail together. For a new app, follow the 600 px rule and show one or the other.
Prefer MediaQuery.sizeOf(context) to MediaQuery.of(context).size. Flutter's guide recommends it for performance: the widget then rebuilds only when the size changes, not when anything else in MediaQuery does.
2. The trap: window width versus the space you were given
The static helpers read the window width. The builder reads the constraints: the space the parent gave this widget. These differ as soon as something else takes up width, such as a navigation rail.
Settings shows this. At a 900 px window, the 66 px rail leaves the settings body less than 850 px, so LayoutBuilder picks the phone layout while the window is tablet-sized. (That only happens for windows from 850 to about 915 px; above that, the body is still 850 px wide after the rail.) Template369's settings screen checks both, and uses the split view whenever the window itself is a small tablet:
return ResponsiveBuilder(
mobileBuilder: (context, constraints) =>
ResponsiveBuilder.isSmallTablet(context) // window 850-1099 px
? _buildDesktopView()
: _buildMobileView(),
tabletBuilder: (context, constraints) => _buildDesktopView(),
desktopBuilder: (context, constraints) => _buildDesktopView(),
); The rule of thumb from Flutter's guide: use LayoutBuilder when a widget should size itself to the space it's given, and MediaQuery.sizeOf when the decision is about the whole window, such as which navigation to show.
3. One navigation, three shapes
The signed-in app has one shell, and its navigation takes a different form at each width. The bottom bar holds the main sections (Home, Settings, Notifications and Plans) with a slide-up sheet for the rest. The rail and side menu hold the full list (Home, Settings, Plans, Components, Contact Support and Sign out), with Notifications reached from the app bar:
| Window | Navigation |
|---|---|
| Below 600 px | Bottom bar; the side menu is hidden and opens as a drawer |
| 600 to 1249 px | Bottom bar plus a compact icon rail (52 px wide below 850 px, 66 px above) |
| 1250 to 1349 px | The rail only; the bottom bar goes away |
| 1350 px and up | A full side menu with labels, which can collapse back to the rail |
The bottom bar is simply left out on desktop:
CustomScaffold(
// ...
bottomNavBar: ResponsiveBuilder.isDesktop(context) || loading
? null
: BottomBarWithSheet(controller: bottomBarController, /* ... */),
) The side menu and rail come from a ResponsiveScaffold adapted from Mike Rydstrom's flex_color_scheme examples (BSD 3-Clause). The important part is that all three shapes read one piece of state, the current section, held in a GetX NavigationController. Tapping "Settings" in the bottom bar, the rail or the menu sets the same value, so the current section survives a resize. A page pushed with Get.to on a narrow window stays a pushed page when the window widens; it doesn't move into the split pane.
Keep the section lists deliberate. In Template369 they live in three places: the side menu items, the bottom bar items and the switch that renders the current section. The bottom bar is a shorter list on purpose, but every section needs a case in the switch. The add-screen agent skill edits all three together.
4. List-detail: push on phones, split on desktop
Settings is a list of items, and each opens a detail screen. On a phone the detail is pushed as a full page. On a wider window it fills the right-hand pane, and the list stays visible:
onTap: (BuildContext context) {
if (ResponsiveBuilder.isMobile(context)) {
Get.to(
() => const ManageThemeScreen(showAppBar: true),
routeName: Constants.settingsScreen,
);
} else {
setState(() {
_navigationController.settingsCurrentSubScreen =
const ManageThemeScreen(showAppBar: false);
});
}
},Two details make this work:
- One widget, a
showAppBarflag. The pushed page needs its own back button; the pane doesn't. Everything else is shared. routeNamenames the parent section. The shell under a pushed page keeps showing Settings instead of jumping back to Home.
The desktop view is a plain Row: a list capped at 300 px and an Expanded pane for the detail.
5. Check both ends every time
- Run
flutter run -d chromeand drag the window from 360 px to 1440 px. Watch each breakpoint flip, and check that the current section and form state survive it. - Check light and dark mode, and a large text scale, at both ends. Large text breaks narrow layouts first.
- In widget tests, set the window size with
tester.view.physicalSizeandtester.view.devicePixelRatio, then reset it withaddTearDown(tester.view.reset). Template369 doesn't ship a layout test yet; this is the pattern to start one. - If you build with an AI coding agent, write the breakpoints and the "works at both ends" rule into AGENTS.md so the agent checks them too. Template369's definition of done includes it.
6. Starting fresh: the same idea in plain Flutter
If you're not using GetX or a starter kit, here is the pattern with only Flutter's own widgets, following the 600 px rule: a NavigationBar below 600 px and a NavigationRail at 600 and up. One _index drives both, an IndexedStack keeps each section's state, and the content is capped at 1200 px so lines stay readable on wide monitors.
class AppShell extends StatefulWidget {
const AppShell({super.key});
@override
State<AppShell> createState() => _AppShellState();
}
class _AppShellState extends State<AppShell> {
int _index = 0;
static const _sections = [
(icon: Icons.home_outlined, label: 'Home', page: Text('Home')),
(icon: Icons.settings_outlined, label: 'Settings', page: Text('Settings')),
];
void _select(int index) => setState(() => _index = index);
@override
Widget build(BuildContext context) {
// Cap the content width so lines stay readable on wide monitors.
final body = Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1200),
child: IndexedStack(
index: _index,
children: [for (final s in _sections) s.page],
),
),
);
if (MediaQuery.sizeOf(context).width < 600) {
return Scaffold(
body: body,
bottomNavigationBar: NavigationBar(
selectedIndex: _index,
onDestinationSelected: _select,
destinations: [
for (final s in _sections)
NavigationDestination(icon: Icon(s.icon), label: s.label),
],
),
);
}
return Scaffold(
body: Row(
children: [
NavigationRail(
selectedIndex: _index,
onDestinationSelected: _select,
labelType: NavigationRailLabelType.all,
destinations: [
for (final s in _sections)
NavigationRailDestination(
icon: Icon(s.icon),
label: Text(s.label),
),
],
),
const VerticalDivider(width: 1),
Expanded(child: body),
],
),
);
}
}And a widget test that checks both sides of the breakpoint, and that the selected section survives a resize:
Future<void> pumpAt(WidgetTester tester, double width) async {
tester.view.physicalSize = Size(width, 900);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(const MaterialApp(home: AppShell()));
}
void main() {
testWidgets('bottom bar below 600', (tester) async {
await pumpAt(tester, 599);
expect(find.byType(NavigationBar), findsOneWidget);
expect(find.byType(NavigationRail), findsNothing);
});
testWidgets('rail at 600 and up, section survives resize', (tester) async {
await pumpAt(tester, 400);
await tester.tap(find.text('Settings').last);
await tester.pumpAndSettle();
await pumpAt(tester, 600);
expect(find.byType(NavigationRail), findsOneWidget);
expect(find.byType(NavigationBar), findsNothing);
final rail = tester.widget<NavigationRail>(find.byType(NavigationRail));
expect(rail.selectedIndex, 1);
});
} Both snippets pass flutter analyze and flutter test on Flutter 3.47.2. To add a full side menu on desktop, set extended: true on the rail above a wider breakpoint, and switch labelType to NavigationRailLabelType.none at the same time: an extended rail asserts if it's also asked to show labels underneath.
Common mistakes
- Branching on platform.
Platform.isIOStells you nothing about the window. Decide layout by size, and keep platform checks for platform behaviour. - Breakpoints scattered as literals. One screen switches at 800, another at 850, and the app looks broken between them. Even when the values agree, as in Template369, repeated literals make the next change risky.
- Separate phone and desktop screens. Two copies drift apart. Share the content widget and change only the frame around it.
- Navigation state inside the navigation widget. When the bottom bar is swapped for a rail, its state goes with it. Keep the current section in a controller.