Skip to content
AB

Responsive Flutter Is About Constraints, Not Devices

Learn how to build Flutter layouts that work on phones, tablets, desktop windows, and nested panels by responding to available width.

Ashish BhakhandPublished

You are building a product screen. On a phone, the filters should open in a bottom sheet. On a wider screen, they should stay visible beside the products.

The first solution many Flutter developers try is checking the device:

Code
if (Platform.isAndroid || Platform.isIOS) {
  return MobileProductPage();
}

return DesktopProductPage();

This works until someone resizes the desktop app to a narrow window, opens the web app in split screen, or uses a tablet with plenty of room. The platform does not tell you how much space your widget has.

A better question is: how wide is the area available to this widget?

Use LayoutBuilder where the layout changes#

LayoutBuilder gives you the constraints received from the parent. You can use its maximum width to choose an appropriate layout:

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

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        if (constraints.maxWidth < 800) {
          return const CompactProductPage();
        }

        return const Row(
          children: [
            SizedBox(
              width: 280,
              child: ProductFilters(),
            ),
            VerticalDivider(width: 1),
            Expanded(child: ProductGrid()),
          ],
        );
      },
    );
  }
}

This code also works when ProductPage is placed inside a dashboard panel. It reacts to the width it receives instead of assuming that every desktop screen is wide.

Flutter's layout constraints guide explains the parent-child sizing model in detail.

Choose breakpoints from your content#

You do not need a long list of device sizes. Start with a small set of layout modes:

Code
enum LayoutSize {
  compact,
  medium,
  expanded,
}

LayoutSize layoutSizeFor(double width) {
  if (width < 600) return LayoutSize.compact;
  if (width < 1024) return LayoutSize.medium;
  return LayoutSize.expanded;
}

These numbers are starting points. Test your actual screen and move a breakpoint when the content begins to feel cramped.

For example:

  • Compact: one column and bottom navigation.
  • Medium: two columns with filters in a drawer.
  • Expanded: persistent navigation and filters.

The names describe how the interface behaves. They do not claim to identify the physical device.

Keep the breakpoint decision in one place#

If every widget contains its own width checks, the app becomes inconsistent. One screen may treat 700 pixels as compact while another treats it as a tablet layout.

Calculate a layout size once for a screen and pass it to the widgets that need it:

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

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        final size = layoutSizeFor(constraints.maxWidth);

        return DashboardContent(
          navigation: size == LayoutSize.compact
              ? const BottomNavigation()
              : const NavigationSidebar(),
          columns: switch (size) {
            LayoutSize.compact => 1,
            LayoutSize.medium => 2,
            LayoutSize.expanded => 3,
          },
        );
      },
    );
  }
}

Now navigation and grid columns change from the same decision.

Change structure only when necessary#

Some responsive changes affect the whole screen:

  • a Row becomes a Column;
  • a navigation bar becomes a rail;
  • a side panel becomes a bottom sheet.

Other changes only affect a value:

Code
final horizontalPadding = switch (layoutSize) {
  LayoutSize.compact => 16.0,
  LayoutSize.medium => 24.0,
  LayoutSize.expanded => 32.0,
};

Keep these simple. Creating separate mobile and desktop widgets for a padding change usually produces unnecessary duplication.

Watch for unbounded width#

Inside some widgets, such as a horizontal ListView, maximum width can be infinite. Classifying infinity as a screen width would always select the largest layout.

Check whether the constraint is bounded:

Code
final availableWidth = constraints.hasBoundedWidth
    ? constraints.maxWidth
    : MediaQuery.sizeOf(context).width;

The MediaQuery value is a reasonable fallback for choosing a general layout mode. If the child truly requires a finite width, fix the parent by using Expanded, Flexible, SizedBox, or another suitable constraint.

Do not break text accessibility#

Responsive screens must also work when users increase text size. Avoid replacing the system text scale with your own fixed value:

Code
// Avoid this. It can reduce the user's accessibility setting.
MediaQuery(
  data: MediaQuery.of(context).copyWith(
    textScaler: const TextScaler.linear(1.0),
  ),
  child: child,
);

If your design needs a minimum scale on larger screens, keep the user's value when it is higher:

Code
final systemScaler = MediaQuery.textScalerOf(context);
final scaler = systemScaler.clamp(minScaleFactor: 1.1);

Flutter's TextScaler documentation covers the scaling API. Always open important screens with large accessibility text and check for clipping or hidden actions.

Test the exact breakpoint edges#

A responsive test should cover the widths immediately before and after a breakpoint:

Code
testWidgets('shows the sidebar from 800 pixels', (tester) async {
  await pumpProductPage(tester, width: 799);
  expect(find.byType(ProductFilters), findsNothing);

  await pumpProductPage(tester, width: 800);
  expect(find.byType(ProductFilters), findsOneWidget);
});

Also test:

  • a narrow desktop window;
  • landscape phone layout;
  • a widget inside a narrow parent;
  • large accessibility text;
  • custom breakpoints, if the app supports them.

Using Fabrik for this#

If you do not want to build and maintain the shared layout utilities yourself, fabrik_layout provides configurable layout categories, responsive values, orientation information, and accessibility-aware text scaling.

The same rule applies whether you use a package or write a small helper: choose layouts from the space available to the widget, not from a device label.

← Back to all writing