← Articles

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.

Creating responsive layouts in Flutter — ANN Tech