Creating responsive layouts in Flutter
By Ann Tech · 29 June 2025
Responsive layouts in Flutter adapt to different screen sizes — phone portrait, phone landscape, tablet, and desktop web. The techniques range from simple conditional widths to fully adaptive layouts that show entirely different widget trees.
The three breakpoints
A simple, practical breakpoint system:
class Breakpoints {
static const double mobile = 600;
static const double tablet = 1024;
}
extension BreakpointExtension on BuildContext {
double get screenWidth => MediaQuery.of(this).size.width;
bool get isMobile => screenWidth < Breakpoints.mobile;
bool get isTablet => screenWidth >= Breakpoints.mobile && screenWidth < Breakpoints.tablet;
bool get isDesktop => screenWidth >= Breakpoints.tablet;
}
MediaQuery: the foundation
@override
Widget build(BuildContext context) {
final width = MediaQuery.of(context).size.width;
final padding = MediaQuery.of(context).padding;
final isLandscape = MediaQuery.of(context).orientation == Orientation.landscape;
return Scaffold(
body: Padding(
// Respect safe areas (notch, home indicator)
padding: EdgeInsets.fromLTRB(
16 + padding.left,
16,
16 + padding.right,
16 + padding.bottom,
),
child: width > 600
? _buildWideLayout()
: _buildNarrowLayout(),
),
);
}
LayoutBuilder: size from parent
LayoutBuilder gives you the constraints from the parent widget, which may differ from the screen size (useful for widgets that can appear in sidebars or dialogs):
LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth > 600) {
return const TwoColumnLayout();
}
return const SingleColumnLayout();
},
)
Adaptive layouts with side panels
A common tablet pattern: navigation rail + content side by side:
class AdaptiveScaffold extends StatelessWidget {
const AdaptiveScaffold({super.key, required this.body, required this.selectedIndex, required this.onTabChanged});
final Widget body;
final int selectedIndex;
final ValueChanged<int> onTabChanged;
@override
Widget build(BuildContext context) {
final isMobile = context.isMobile;
if (isMobile) {
return Scaffold(
body: body,
bottomNavigationBar: BottomNavigationBar(
currentIndex: selectedIndex,
onTap: onTabChanged,
items: const [
BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
BottomNavigationBarItem(icon: Icon(Icons.list), label: 'Orders'),
BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Profile'),
],
),
);
}
return Scaffold(
body: Row(
children: [
NavigationRail(
selectedIndex: selectedIndex,
onDestinationSelected: onTabChanged,
labelType: NavigationRailLabelType.all,
destinations: const [
NavigationRailDestination(icon: Icon(Icons.home), label: Text('Home')),
NavigationRailDestination(icon: Icon(Icons.list), label: Text('Orders')),
NavigationRailDestination(icon: Icon(Icons.person), label: Text('Profile')),
],
),
const VerticalDivider(thickness: 1, width: 1),
Expanded(child: body),
],
),
);
}
}
Flexible and Expanded in rows/columns
// Items split available space proportionally
Row(
children: [
Flexible(
flex: 2,
child: ProductImage(), // Gets 2/3 of available width
),
Flexible(
flex: 1,
child: ProductInfo(), // Gets 1/3 of available width
),
],
)
// Expanded fills remaining space after fixed-width siblings
Row(
children: [
const SizedBox(width: 60, child: ThumbnailImage()),
const SizedBox(width: 12),
Expanded(
child: ProductTitle(), // Fills the rest, handles overflow with ellipsis
),
const AddToCartButton(),
],
)
Wrap for tag/chip lists
Wrap flows children to the next line when they don't fit:
Wrap(
spacing: 8,
runSpacing: 8,
children: product.tags
.map((tag) => Chip(label: Text(tag)))
.toList(),
)
GridView for responsive grids
GridView.builder(
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 200, // Max width per cell — adapts column count automatically
crossAxisSpacing: 16,
mainAxisSpacing: 16,
childAspectRatio: 0.75,
),
itemCount: products.length,
itemBuilder: (_, i) => ProductCard(product: products[i]),
)
maxCrossAxisExtent: 200 means:
- 400px wide: 2 columns
- 600px wide: 3 columns
- 1200px wide: 6 columns
FittedBox for scaling content
Scale a widget to fit its container:
FittedBox(
fit: BoxFit.scaleDown, // Shrink if too big, don't enlarge
child: Row(
children: [
const Icon(Icons.star, color: Colors.amber),
Text('$rating (${reviews} reviews)'),
],
),
)
Text scaling and accessibility
// Don't clamp text scale too aggressively — it breaks accessibility
final textScaler = MediaQuery.textScalerOf(context);
// Only cap it for decorative small text
Text(
'SOLD OUT',
textScaler: TextScaler.linear(textScaler.scale(1.0).clamp(1.0, 1.5)),
)
Common pitfalls
Hardcoding pixel widths. width: 375 only works on the device it was designed for. Use percentages (MediaQuery.of(context).size.width * 0.8) or Flexible/Expanded.
ListView inside a Column without a height constraint. ListView wants infinite height inside a Column. Wrap in Expanded or give it an explicit height.
Not testing landscape orientation. Many layout bugs only appear in landscape. Test by rotating the device or resizing the simulator.
Sign in to like, dislike, or report.