Creating responsive layouts in Flutter
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.