Designing for multiple screen sizes in Flutter
By Ann Tech · 2 April 2026
Flutter runs on phones, tablets, foldables, web, and desktop — all from one codebase. But a layout designed for a 375pt phone looks broken on a 1024pt tablet. Responsive design bridges that gap.
Reading screen dimensions
final size = MediaQuery.of(context).size;
final width = size.width;
final height = size.height;
// Safer: LayoutBuilder gives you constraints from the parent, not the screen
LayoutBuilder(
builder: (context, constraints) {
final isWide = constraints.maxWidth > 600;
return isWide ? WideLayout() : NarrowLayout();
},
)
Breakpoints
Define breakpoints as constants so they're consistent across the app:
class Breakpoints {
static const double mobile = 600;
static const double tablet = 900;
static const double desktop = 1200;
}
extension ScreenType on BuildContext {
bool get isMobile => MediaQuery.of(this).size.width < Breakpoints.mobile;
bool get isTablet =>
MediaQuery.of(this).size.width >= Breakpoints.mobile &&
MediaQuery.of(this).size.width < Breakpoints.tablet;
bool get isDesktop => MediaQuery.of(this).size.width >= Breakpoints.tablet;
}
// Usage:
if (context.isMobile) return const MobileLayout();
if (context.isTablet) return const TabletLayout();
return const DesktopLayout();
Adaptive navigation
On mobile, a bottom navigation bar is standard. On tablet/desktop, a side rail or drawer fits better:
class AdaptiveScaffold extends StatelessWidget {
const AdaptiveScaffold({super.key, required this.body, required this.destinations});
final Widget body;
final List<NavigationDestination> destinations;
@override
Widget build(BuildContext context) {
if (context.isDesktop) {
return Scaffold(
body: Row(
children: [
NavigationRail(
destinations: destinations
.map((d) => NavigationRailDestination(
icon: d.icon,
label: Text(d.label),
))
.toList(),
selectedIndex: 0,
),
Expanded(child: body),
],
),
);
}
return Scaffold(
body: body,
bottomNavigationBar: NavigationBar(destinations: destinations),
);
}
}
Flexible grid layouts
// Adapts column count based on available width
GridView.builder(
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 300, // Each cell max 300px wide
crossAxisSpacing: 16,
mainAxisSpacing: 16,
childAspectRatio: 0.75,
),
itemCount: products.length,
itemBuilder: (_, i) => ProductCard(product: products[i]),
)
Responsive typography
extension ResponsiveTextTheme on BuildContext {
TextTheme get responsiveTextTheme {
final base = Theme.of(this).textTheme;
if (isDesktop) {
return base.copyWith(
displayLarge: base.displayLarge?.copyWith(fontSize: 96),
headlineMedium: base.headlineMedium?.copyWith(fontSize: 48),
);
}
return base;
}
}
Testing multiple sizes
testWidgets('shows rail on wide screen', (tester) async {
await tester.binding.setSurfaceSize(const Size(1200, 800));
await tester.pumpWidget(const MaterialApp(home: HomeScreen()));
expect(find.byType(NavigationRail), findsOneWidget);
expect(find.byType(NavigationBar), findsNothing);
});
testWidgets('shows bottom nav on mobile', (tester) async {
await tester.binding.setSurfaceSize(const Size(375, 812));
await tester.pumpWidget(const MaterialApp(home: HomeScreen()));
expect(find.byType(NavigationBar), findsOneWidget);
expect(find.byType(NavigationRail), findsNothing);
});
Common pitfalls
Hard-coding pixel values. Container(width: 320) looks right on one phone and broken on every other screen. Use FractionallySizedBox, Expanded, or LayoutBuilder constraints instead.
Only testing on one device. During development, switch between a phone simulator and a tablet simulator regularly. Issues only visible on tablet are invisible on phone, and vice versa.
Forgetting safe areas. On notched and punch-hole screens, content can hide behind the camera cutout. Always wrap content in SafeArea or handle MediaQuery.of(context).padding explicitly.
Sign in to like, dislike, or report.