A client once handed me an iPhone 15 Pro Max and said "the app looks great." Then I opened it on their iPad. Buttons were tiny, text was swimming in whitespace, and the single-column layout wasted 60% of the screen. The app technically worked on every device. It just looked terrible on anything wider than 428 pixels.
This is the most common mistake in Flutter development: building for one screen size and calling it done. Flutter gives you the tools to build truly responsive apps — apps that look intentional on a 5-inch phone, a 10-inch tablet, and a 27-inch monitor. But you have to actually use them. This guide covers the widgets, patterns, and architecture you need to make your Flutter app feel native on every screen size.
What You'll Learn
- The practical difference between responsive and adaptive design
- Using
LayoutBuilderandMediaQuerycorrectly (and when to use which) - Setting up breakpoints that actually make sense
- Building a scaffold that adapts between
BottomNavigationBar,NavigationRail, and sidebar - Responsive grids, typography, and image handling
- Platform-adaptive widgets for iOS/Android/Desktop differences
1. Responsive vs Adaptive: Know the Difference
These terms get used interchangeably, but they describe different things:
Responsive means your layout adjusts fluidly to available space. A grid that shows 2 columns on a phone and 4 columns on a tablet. Padding that grows as the screen widens. Text that doesn't overflow at any width. It's about how much space you have.
Adaptive means you swap out entire components based on the platform or form factor. A
BottomNavigationBar on phones becomes a NavigationRail on tablets becomes a permanent
sidebar on desktop. An AlertDialog on mobile becomes a side panel on desktop. It's about what
kind of device you're on.
Production apps need both. Responsive within each form factor, adaptive across form factors.
| Aspect | Responsive | Adaptive |
|---|---|---|
| Approach | Fluid — adjusts proportionally | Discrete — swaps components |
| Key widget | LayoutBuilder, Flex |
Platform.isIOS, breakpoint switches |
| Example | 2-col grid → 4-col grid | Bottom nav → side rail |
| Triggers on | Screen width changes | Form factor / platform changes |
| Complexity | Lower | Higher |
2. MediaQuery: Reading Screen and System Properties
MediaQuery tells you about the screen and user settings. Screen size,
orientation, text scale factor, padding (notch areas, navigation bars), platform brightness. It's your window into
the device's physical properties.
Basic Usage
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.sizeOf(context).width;
final screenHeight = MediaQuery.sizeOf(context).height;
final textScaler = MediaQuery.textScalerOf(context);
final padding = MediaQuery.paddingOf(context); // Safe area
final orientation = MediaQuery.orientationOf(context);
final platformBrightness = MediaQuery.platformBrightnessOf(context);
// Use sizeOf/paddingOf instead of MediaQuery.of(context).size
// The specific accessors cause fewer rebuilds
return Text('Width: $screenWidth');
}
The MediaQuery.of(context) Rebuild Trap
Here's a mistake I made for years: using MediaQuery.of(context).size.width everywhere. The problem?
MediaQuery.of(context) returns the FULL MediaQueryData object, so your widget rebuilds
whenever any property changes — not just the one you care about. The keyboard opening changes
viewInsets, which triggers a rebuild of every widget using MediaQuery.of(context).
Flutter 3.10+ added specific accessors that only trigger rebuilds for the property you're actually reading:
// BAD: Rebuilds when ANY MediaQuery property changes
final width = MediaQuery.of(context).size.width;
// GOOD: Only rebuilds when size changes
final width = MediaQuery.sizeOf(context).width;
// GOOD: Only rebuilds when text scale changes
final scaler = MediaQuery.textScalerOf(context);
// GOOD: Only rebuilds when padding changes
final padding = MediaQuery.paddingOf(context);
3. LayoutBuilder: The Real Responsive Workhorse
LayoutBuilder is more useful than MediaQuery for responsive
layouts. Why? Because it tells you the constraints of the parent, not the whole screen. A widget inside a
sidebar doesn't care that the screen is 1400px wide — it cares that its parent gives it 300px.
class ResponsiveContent extends StatelessWidget {
const ResponsiveContent({super.key});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth >= 1200) {
return const WideLayout(); // Desktop: 3 columns
} else if (constraints.maxWidth >= 600) {
return const MediumLayout(); // Tablet: 2 columns
} else {
return const NarrowLayout(); // Phone: 1 column
}
},
);
}
}
LayoutBuilder only rebuilds when the parent constraints change. Keyboard opens? If it doesn't change
the parent's width constraints, LayoutBuilder doesn't rebuild. Screen rotates? Now the width
constraint changes, so it responds. That's the behavior you want.
LayoutBuilder vs MediaQuery: When to Use Which
| Use Case | Best Tool | Why |
|---|---|---|
| Choose between column layouts | LayoutBuilder |
Responds to parent, not screen |
| Decide navigation type | MediaQuery.sizeOf |
Global decision, not local |
| Read safe area padding | MediaQuery.paddingOf |
System-level property |
| Responsive grid columns | LayoutBuilder |
Based on available space |
| Text scale factor | MediaQuery.textScalerOf |
User accessibility setting |
| Decide dialog vs panel | MediaQuery.sizeOf |
Full screen context needed |
4. Defining Breakpoints for Your App
Material Design 3 defines three window size classes. These work well as starting points:
abstract class Breakpoints {
/// Phone portrait (up to 599px)
static const double compact = 600;
/// Tablet portrait, phone landscape (600-839px)
static const double medium = 840;
/// Tablet landscape, desktop (840-1199px)
static const double expanded = 1200;
/// Large desktop (1200px+)
static const double large = 1600;
static bool isCompact(double width) => width < compact;
static bool isMedium(double width) => width >= compact && width < medium;
static bool isExpanded(double width) => width >= medium && width < expanded;
static bool isLarge(double width) => width >= expanded;
static int gridColumns(double width) {
if (width < compact) return 4;
if (width < medium) return 8;
return 12;
}
static double contentMaxWidth(double width) {
if (width >= large) return 1200;
if (width >= expanded) return 960;
return double.infinity; // Use full width on phone/tablet
}
}
Put this in a single file and import it everywhere. Don't scatter magic numbers like 600 and
840 across your codebase — that's a recipe for inconsistency.
5. Building a Responsive Scaffold
The most visible responsive change in any app is the navigation pattern. Phone users expect bottom tabs. Tablet users expect a navigation rail. Desktop users expect a sidebar. Here's how to build a scaffold that adapts:
class AdaptiveScaffold extends StatefulWidget {
const AdaptiveScaffold({super.key});
@override
State<AdaptiveScaffold> createState() => _AdaptiveScaffoldState();
}
class _AdaptiveScaffoldState extends State<AdaptiveScaffold> {
int _selectedIndex = 0;
static const _destinations = [
NavigationDestination(icon: Icon(Icons.home_outlined), selectedIcon: Icon(Icons.home), label: 'Home'),
NavigationDestination(icon: Icon(Icons.search_outlined), selectedIcon: Icon(Icons.search), label: 'Search'),
NavigationDestination(icon: Icon(Icons.favorite_outline), selectedIcon: Icon(Icons.favorite), label: 'Saved'),
NavigationDestination(icon: Icon(Icons.person_outline), selectedIcon: Icon(Icons.person), label: 'Profile'),
];
static const _railDestinations = [
NavigationRailDestination(icon: Icon(Icons.home_outlined), selectedIcon: Icon(Icons.home), label: Text('Home')),
NavigationRailDestination(icon: Icon(Icons.search_outlined), selectedIcon: Icon(Icons.search), label: Text('Search')),
NavigationRailDestination(icon: Icon(Icons.favorite_outline), selectedIcon: Icon(Icons.favorite), label: Text('Saved')),
NavigationRailDestination(icon: Icon(Icons.person_outline), selectedIcon: Icon(Icons.person), label: Text('Profile')),
];
final _pages = const [
HomeScreen(),
SearchScreen(),
SavedScreen(),
ProfileScreen(),
];
@override
Widget build(BuildContext context) {
final width = MediaQuery.sizeOf(context).width;
// Desktop: permanent sidebar
if (width >= Breakpoints.expanded) {
return Scaffold(
body: Row(
children: [
NavigationDrawer(
selectedIndex: _selectedIndex,
onDestinationSelected: (i) => setState(() => _selectedIndex = i),
children: [
const Padding(
padding: EdgeInsets.fromLTRB(28, 16, 16, 10),
child: Text('My App', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
),
...List.generate(_destinations.length, (i) =>
NavigationDrawerDestination(
icon: _destinations[i].icon,
selectedIcon: (_destinations[i] as NavigationDestination).selectedIcon,
label: Text(_destinations[i].label),
),
),
],
),
const VerticalDivider(thickness: 1, width: 1),
Expanded(child: _pages[_selectedIndex]),
],
),
);
}
// Tablet: navigation rail
if (width >= Breakpoints.compact) {
return Scaffold(
body: Row(
children: [
NavigationRail(
selectedIndex: _selectedIndex,
onDestinationSelected: (i) => setState(() => _selectedIndex = i),
labelType: NavigationRailLabelType.all,
destinations: _railDestinations,
),
const VerticalDivider(thickness: 1, width: 1),
Expanded(child: _pages[_selectedIndex]),
],
),
);
}
// Phone: bottom navigation bar
return Scaffold(
body: _pages[_selectedIndex],
bottomNavigationBar: NavigationBar(
selectedIndex: _selectedIndex,
onDestinationSelected: (i) => setState(() => _selectedIndex = i),
destinations: _destinations,
),
);
}
}
This is the pattern Google uses in their own Material apps. Three navigation modes, one codebase, zero visual compromise.
6. Responsive Grids and Lists
Most content-heavy screens need a grid that adapts its column count. Don't hardcode 2 columns — calculate it from available width.
Dynamic Grid Columns
class ResponsiveGrid extends StatelessWidget {
final List<Widget> children;
final double minItemWidth;
final double spacing;
const ResponsiveGrid({
super.key,
required this.children,
this.minItemWidth = 300,
this.spacing = 16,
});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final columns = (constraints.maxWidth / minItemWidth).floor().clamp(1, 6);
final itemWidth = (constraints.maxWidth - spacing * (columns - 1)) / columns;
return Wrap(
spacing: spacing,
runSpacing: spacing,
children: children.map((child) =>
SizedBox(width: itemWidth, child: child),
).toList(),
);
},
);
}
}
Or use GridView with SliverGridDelegateWithMaxCrossAxisExtent — it calculates column
count automatically:
GridView.builder(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 350, // Max width per item
crossAxisSpacing: 16,
mainAxisSpacing: 16,
childAspectRatio: 3 / 4, // Width:Height ratio
),
itemCount: items.length,
itemBuilder: (context, index) => ItemCard(item: items[index]),
)
List-to-Grid Transition
On phones, show a vertical list. On tablets and up, show a grid:
class AdaptiveList extends StatelessWidget {
final List<Product> products;
const AdaptiveList({super.key, required this.products});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth < 600) {
// Phone: vertical list
return ListView.builder(
itemCount: products.length,
itemBuilder: (context, i) => ProductListTile(product: products[i]),
);
}
// Tablet+: grid
return GridView.builder(
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: constraints.maxWidth < 900 ? 300 : 350,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
childAspectRatio: 0.75,
),
itemCount: products.length,
itemBuilder: (context, i) => ProductGridCard(product: products[i]),
);
},
);
}
}
7. Responsive Typography and Spacing
Don't scale text proportionally to screen width. Seriously. I've seen codebases where body text is
screenWidth * 0.04 — that's 6px on a small phone and 56px on a 4K monitor. Unreadable at both
extremes.
Use Material's Type Scale
Material Design 3's type scale handles responsive text correctly. It uses fixed sizes that are readable on all devices:
// These work on every screen size — don't reinvent them
Text('Page Title', style: Theme.of(context).textTheme.headlineLarge);
Text('Section Title', style: Theme.of(context).textTheme.headlineMedium);
Text('Card Title', style: Theme.of(context).textTheme.titleLarge);
Text('Body text', style: Theme.of(context).textTheme.bodyLarge);
Text('Caption', style: Theme.of(context).textTheme.bodySmall);
Stepping Text Sizes by Breakpoint
If you need different text sizes across form factors, step them — don't scale them:
extension ResponsiveTextTheme on BuildContext {
TextStyle get pageTitle {
final width = MediaQuery.sizeOf(this).width;
final base = Theme.of(this).textTheme.headlineLarge!;
if (width >= 1200) return base.copyWith(fontSize: 36);
if (width >= 840) return base.copyWith(fontSize: 32);
return base; // Default: 32 on phone (Material default)
}
double get horizontalPadding {
final width = MediaQuery.sizeOf(this).width;
if (width >= 1200) return 48;
if (width >= 840) return 32;
return 16;
}
}
Respecting Text Scale Factor
Always respect the user's system text scale setting. Some users set 1.5x or 2x text for accessibility. If you hardcode widget sizes that barely fit default text, they'll overflow at larger scales:
// BAD: Fixed height that breaks at 1.5x text scale
SizedBox(height: 48, child: Text('Button Label'))
// GOOD: Minimum height that grows with text scale
ConstrainedBox(
constraints: const BoxConstraints(minHeight: 48),
child: Text('Button Label'),
)
8. Platform-Adaptive Widgets
Beyond screen size, your app should feel native to the platform. iOS users expect a
CupertinoNavigationBar. Android users expect a Material AppBar. Desktop users expect
hover states and keyboard shortcuts.
import 'dart:io' show Platform;
class AdaptiveDialog {
static Future<bool?> show(BuildContext context, {
required String title,
required String content,
}) {
if (Platform.isIOS || Platform.isMacOS) {
return showCupertinoDialog<bool>(
context: context,
builder: (context) => CupertinoAlertDialog(
title: Text(title),
content: Text(content),
actions: [
CupertinoDialogAction(
isDestructiveAction: true,
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
CupertinoDialogAction(
onPressed: () => Navigator.pop(context, true),
child: const Text('Confirm'),
),
],
),
);
}
return showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: Text(content),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('CANCEL'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('CONFIRM'),
),
],
),
);
}
}
Mouse and Hover Support for Desktop
Desktop users expect hover states. Cards should visually respond when the cursor enters them:
class HoverCard extends StatefulWidget {
final Widget child;
const HoverCard({super.key, required this.child});
@override
State<HoverCard> createState() => _HoverCardState();
}
class _HoverCardState extends State<HoverCard> {
bool _isHovered = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
transform: _isHovered
? (Matrix4.identity()..scale(1.02))
: Matrix4.identity(),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: _isHovered ? 0.15 : 0.08),
blurRadius: _isHovered ? 16 : 8,
offset: Offset(0, _isHovered ? 8 : 4),
),
],
),
child: widget.child,
),
);
}
}
9. Responsive Images and Assets
Loading a 1200px-wide hero image on a 360px phone wastes bandwidth and slows rendering. Loading a 360px image on a 4K desktop looks blurry. You need to serve the right image for the right screen.
Resolution-Aware Asset Loading
Flutter handles asset resolution for local images automatically if you follow the naming convention:
assets/
images/
hero.png # 1x (360px)
2.0x/hero.png # 2x (720px)
3.0x/hero.png # 3x (1080px)
// Flutter picks the right one based on device pixel ratio
Image.asset('assets/images/hero.png')
Responsive Network Images
For network images, use LayoutBuilder to request the right size:
LayoutBuilder(
builder: (context, constraints) {
final pixelRatio = MediaQuery.devicePixelRatioOf(context);
final imageWidth = (constraints.maxWidth * pixelRatio).toInt();
return Image.network(
'https://cdn.example.com/image.jpg?w=$imageWidth&q=80',
width: constraints.maxWidth,
fit: BoxFit.cover,
);
},
)
10. Testing Responsive Layouts
You can't eyeball responsive design on one device and ship it. Test systematically.
Widget Tests at Multiple Sizes
const testSizes = {
'small_phone': Size(360, 640),
'large_phone': Size(414, 896),
'tablet_portrait': Size(768, 1024),
'tablet_landscape': Size(1024, 768),
'desktop': Size(1440, 900),
};
for (final entry in testSizes.entries) {
testWidgets('Home screen renders on ${entry.key}', (tester) async {
tester.view.physicalSize = entry.value;
tester.view.devicePixelRatio = 1.0;
await tester.pumpWidget(const MaterialApp(home: HomeScreen()));
await tester.pumpAndSettle();
// Verify no overflow
expect(tester.takeException(), isNull);
// On phone: bottom nav visible
if (entry.value.width < 600) {
expect(find.byType(NavigationBar), findsOneWidget);
expect(find.byType(NavigationRail), findsNothing);
}
// On tablet: navigation rail visible
else if (entry.value.width < 840) {
expect(find.byType(NavigationRail), findsOneWidget);
expect(find.byType(NavigationBar), findsNothing);
}
addTearDown(() => tester.view.resetPhysicalSize());
});
}
Using Device Preview
The device_preview package lets you test every screen size in your running app without deploying to physical devices:
void main() {
runApp(
DevicePreview(
enabled: !kReleaseMode,
builder: (context) => const MyApp(),
),
);
}
11. Production Checklist
- No hardcoded sizes for text containers. Use
ConstrainedBoxwithminHeightinstead of fixedSizedBox. - All padding uses Breakpoints. No
EdgeInsets.all(16)scattered everywhere — centralize horizontal padding based on screen width. - Navigation adapts to form factor. Bottom nav on phone, rail on tablet, drawer/sidebar on desktop.
- Don't use
MediaQuery.of(context). Use the specific accessors:sizeOf,paddingOf,textScalerOf. - LayoutBuilder for local responsive decisions. MediaQuery for global decisions (navigation pattern, app-level layout).
- Tested at 360px, 414px, 768px, 1024px, and 1440px. Test both portrait and landscape at phone and tablet sizes.
- Text respects system scale factor. Don't override
textScaleFactorunless you absolutely must. - Desktop has hover states. Cards, buttons, and list items should visually respond to mouse hover.
- Images are resolution-aware. Don't load 3x assets on 1x screens. Use Flutter's resolution system for local assets and URL-based sizing for network images.
- No overflow errors at any tested size. The yellow-and-black overflow warning should never appear in production.
Related Guides
- Mastering Flutter Layouts: Flex, Stack, and Custom Layouts
- Flutter Dark Mode: Complete Implementation Guide
- Mastering Flutter Custom Widgets
- Flutter Animations Masterclass: From Basic to Advanced
- Flutter Performance: Optimization Techniques That Work
- Flutter Testing Strategy: Unit, Widget, and Integration Tests
- Flutter Clean Architecture: A Practical Guide
- BLoC vs Riverpod: Which State Management to Choose in 2026
- Flutter WASM: Building High-Performance Web Apps
- Creating Beautiful Forms in Flutter
Frequently Asked Questions
What's the difference between responsive and adaptive design in Flutter?
Responsive design means your layout fluidly adjusts to available space — a column becomes a grid, text scales proportionally, padding changes with screen width. Adaptive design means you show entirely different widgets or layouts based on the platform or form factor — a bottom nav bar on phone, a navigation rail on tablet, a sidebar on desktop. Most production Flutter apps need both.
Should I use MediaQuery or LayoutBuilder?
Use LayoutBuilder for most responsive layouts. It gives you the actual constraints of the parent
widget, not the full screen size. MediaQuery is useful for global decisions (is this a tablet?)
and for reading system-level values like text scale factor and safe area padding.
What breakpoints should I use for Flutter responsive design?
Material Design 3 uses 600px (compact), 840px (medium), and 1200px+ (expanded). These work well as starting points. Adjust based on your content — a data-heavy app might need different breakpoints than a social media app.
How do I handle responsive text sizes in Flutter?
Don't scale text proportionally to screen width. Use Material Design's type scale
(Theme.of(context).textTheme) which handles sizing correctly across devices. If you need custom
scaling, scale in discrete steps at breakpoints. Always respect the user's system text scale factor.
Do I need a package for responsive design?
Flutter's built-in widgets (LayoutBuilder, MediaQuery, Flex,
Wrap, FractionallySizedBox) handle 90% of responsive design needs. Packages like responsive_builder or responsive_framework add convenience APIs but aren't necessary. Start with
built-in tools, add a package only if you find yourself writing the same breakpoint logic repeatedly.
How do I test my Flutter app on different screen sizes?
Use the Device Preview
package during development. For widget tests, set tester.view.physicalSize to test at specific
dimensions. Test at minimum: small phone (360x640), large phone (414x896), tablet portrait (768x1024), and
tablet landscape (1024x768).