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:
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:
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:
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:
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:
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:
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:
// 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:
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:
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.