Responsive Flutter Layouts: One App for Phone, Tablet and Web

Make your Flutter UI responsive with MediaQuery, LayoutBuilder, breakpoints and adaptive navigation. Build one layout that looks right on phones, tablets, desktop and the web.

By Yaqoob Developer · · 5 min read

  • #Flutter
  • #Responsive Design
  • #LayoutBuilder
  • #Flutter Web
  • #Flutter UI

Flutter runs on phones, tablets, laptops and in the browser. But a screen designed for a 390-pixel-wide phone looks empty and stretched on a 1400-pixel laptop. Text lines become too long to read, buttons stretch across the whole screen, and grids show two giant cards.

A responsive layout fixes this. In this guide you will learn:

  • The difference between MediaQuery and LayoutBuilder
  • How to choose breakpoints
  • How to cap content width so it never stretches too far
  • Grids that add columns as the screen grows
  • Switching between a bottom bar and a side rail

MediaQuery vs LayoutBuilder

Both tell you how much space you have, but they measure different things.

MediaQuery gives you the size of the whole screen (the app window):

final width = MediaQuery.sizeOf(context).width;

LayoutBuilder gives you the space available to this widget, set by its parent:

LayoutBuilder(
  builder: (context, constraints) {
    if (constraints.maxWidth > 600) {
      return const WideLayout();
    }
    return const NarrowLayout();
  },
)

Which to use?

  • Use MediaQuery for app-level decisions: which navigation to show, how many panes.
  • Use LayoutBuilder for components: a card that changes layout when it is narrow, no matter how big the screen is.

Use MediaQuery.sizeOf(context) rather than MediaQuery.of(context).size. The sizeOf version only rebuilds your widget when the size changes, not when anything else in MediaQuery changes (like the keyboard opening).

Step 1: Breakpoints

A breakpoint is a width where your layout changes. Material 3 uses these, and they work well for most apps:

WidthWindow classTypical device
under 600CompactPhones
600 to 839MediumSmall tablets, foldables
840 and aboveExpandedLarge tablets, laptops, desktop

Put them in one place so the whole app agrees:

import 'package:flutter/material.dart';

enum ScreenSize { compact, medium, expanded }

ScreenSize screenSizeOf(BuildContext context) {
  final width = MediaQuery.sizeOf(context).width;
  if (width >= 840) return ScreenSize.expanded;
  if (width >= 600) return ScreenSize.medium;
  return ScreenSize.compact;
}

Step 2: Cap the content width

The quickest win for tablets and web: don't let content grow wider than a comfortable reading width. Wrap the page body:

class MaxWidth extends StatelessWidget {
  const MaxWidth({super.key, this.maxWidth = 720, required this.child});

  final double maxWidth;
  final Widget child;

  @override
  Widget build(BuildContext context) {
    return Align(
      alignment: Alignment.topCenter,
      child: ConstrainedBox(
        constraints: BoxConstraints(maxWidth: maxWidth),
        child: child,
      ),
    );
  }
}

Use it on forms, settings pages and articles:

Scaffold(
  body: MaxWidth(
    child: ListView(
      padding: const EdgeInsets.all(24),
      children: const [/* ... */],
    ),
  ),
)

A login form that is 1400 pixels wide looks wrong. The same form capped at 420 pixels and centered looks designed. Try it on any of the login screen projects on this site.

Step 3: Grids that add columns

Don't hard-code crossAxisCount: 2. Tell the grid how wide each item may be, and let it calculate the number of columns:

GridView.builder(
  padding: const EdgeInsets.all(16),
  gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
    maxCrossAxisExtent: 220,
    mainAxisSpacing: 16,
    crossAxisSpacing: 16,
    childAspectRatio: 3 / 4,
  ),
  itemCount: products.length,
  itemBuilder: (context, i) => ProductCard(product: products[i]),
)

With maxCrossAxisExtent: 220 you get 2 columns on a phone, 3 or 4 on a tablet, and 6 or more on a desktop, with no if statements. If grids are new to you, start with the ListView and GridView guide.

Step 4: Adaptive navigation

On phones, navigation belongs at the bottom where thumbs reach. On tablets and desktop, a side NavigationRail uses the extra width better:

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

  @override
  State<AdaptiveShell> createState() => _AdaptiveShellState();
}

class _AdaptiveShellState extends State<AdaptiveShell> {
  int _index = 0;

  static const _pages = [
    Center(child: Text('Home')),
    Center(child: Text('Search')),
    Center(child: Text('Profile')),
  ];

  @override
  Widget build(BuildContext context) {
    final wide = MediaQuery.sizeOf(context).width >= 600;

    if (wide) {
      return Scaffold(
        body: Row(
          children: [
            NavigationRail(
              selectedIndex: _index,
              onDestinationSelected: (i) => setState(() => _index = i),
              labelType: NavigationRailLabelType.all,
              destinations: const [
                NavigationRailDestination(icon: Icon(Icons.home), label: Text('Home')),
                NavigationRailDestination(icon: Icon(Icons.search), label: Text('Search')),
                NavigationRailDestination(icon: Icon(Icons.person), label: Text('Profile')),
              ],
            ),
            const VerticalDivider(width: 1),
            Expanded(child: _pages[_index]),
          ],
        ),
      );
    }

    return Scaffold(
      body: _pages[_index],
      bottomNavigationBar: NavigationBar(
        selectedIndex: _index,
        onDestinationSelected: (i) => setState(() => _index = i),
        destinations: const [
          NavigationDestination(icon: Icon(Icons.home), label: 'Home'),
          NavigationDestination(icon: Icon(Icons.search), label: 'Search'),
          NavigationDestination(icon: Icon(Icons.person), label: 'Profile'),
        ],
      ),
    );
  }
}

The selected index lives in the state, so rotating a tablet from portrait to landscape keeps you on the same tab. For more on the bottom bar itself, read the NavigationBar guide.

Step 5: Two panes on large screens

Email and chat apps show the list and the selected item side by side on large screens. With the breakpoints from Step 1:

@override
Widget build(BuildContext context) {
  if (screenSizeOf(context) == ScreenSize.expanded) {
    return Row(
      children: [
        const SizedBox(width: 360, child: ChatList()),
        const VerticalDivider(width: 1),
        Expanded(child: ChatDetail(chat: _selected)),
      ],
    );
  }
  return const ChatList(); // tapping a chat pushes ChatDetail
}

The same two widgets are reused. Only the arrangement changes. See the chat app messages UI for a design that works well this way.

Things that are not about width

Responsive design is also about:

  • Text scaling. Users can make text larger in their phone settings. Don't put text in fixed-height boxes, and test with the largest text size.
  • Orientation. Landscape phones are wide but very short. A layout that fits a tablet may overflow vertically on a landscape phone. Scrollable pages avoid this.
  • Mouse and keyboard. On web and desktop, add hover effects and make sure Tab moves between fields.

Common mistakes

Using fixed widths everywhere. width: 360 looks fine on your test phone and broken everywhere else. Prefer Expanded, Flexible, percentages from constraints, and max widths.

Scaling everything by screen width. Packages that make every font and padding grow with the screen make tablets look like giant phones. Show more content on bigger screens, not bigger content.

Checking Platform.isAndroid to decide the layout. Platform tells you the operating system, not the screen size. An Android tablet and an Android phone need different layouts.

Summary

  • MediaQuery.sizeOf for app-level decisions, LayoutBuilder for components.
  • Use Material breakpoints: 600 and 840.
  • Cap content with ConstrainedBox(maxWidth).
  • Let grids pick their columns with SliverGridDelegateWithMaxCrossAxisExtent.
  • Switch NavigationBar to NavigationRail on wide screens.

Keep reading

More articles