Skip to content
FlutterLearn

Intermediate · Lesson 6 · 12 min read

Responsive and Adaptive UI

Make one codebase look right on phones, tablets, desktop and web — with layout breakpoints, adaptive components and correct theming.

Updated July 30, 2026

What you will learn

  • Distinguish responsive layout from adaptive behaviour
  • Use LayoutBuilder and breakpoints instead of device checks
  • Build a Material 3 theme with light and dark variants
  • Handle safe areas, text scaling and pointer input correctly

Responsive means the layout reflows to the space available. Adaptive means the behaviour and components change to match the platform's conventions. A good app does both, and neither should be done by checking Platform.isIOS in your widgets.

Measure the space, not the device

LayoutBuilder gives you the constraints of the box you are actually in — which is what matters, since your widget might sit in a side panel rather than the full screen.

lib/layout/breakpoints.dart
enum FormFactor { compact, medium, expanded }

FormFactor formFactorFor(double width) {
  if (width < 600) return FormFactor.compact;   // phone
  if (width < 900) return FormFactor.medium;    // small tablet / split view
  return FormFactor.expanded;                   // tablet landscape, desktop
}

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) {
    return LayoutBuilder(
      builder: (context, constraints) {
        final factor = formFactorFor(constraints.maxWidth);

        return switch (factor) {
          FormFactor.compact => Scaffold(
              body: body,
              bottomNavigationBar: NavigationBar(destinations: destinations),
            ),
          FormFactor.medium => Scaffold(
              body: Row(
                children: [
                  const NavigationRail(destinations: [], selectedIndex: 0),
                  const VerticalDivider(width: 1),
                  Expanded(child: body),
                ],
              ),
            ),
          FormFactor.expanded => Scaffold(
              body: Row(
                children: [
                  const SizedBox(width: 260, child: NavigationDrawer(children: [])),
                  const VerticalDivider(width: 1),
                  Expanded(child: body),
                ],
              ),
            ),
        };
      },
    );
  }
}

Grids that reflow

Dart
// Fixed column count is wrong on tablets — fix the tile width instead
GridView.builder(
  padding: const EdgeInsets.all(16),
  gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
    maxCrossAxisExtent: 240,   // as many columns as fit at <= 240 each
    mainAxisSpacing: 16,
    crossAxisSpacing: 16,
    childAspectRatio: 3 / 4,
  ),
  itemCount: products.length,
  itemBuilder: (context, i) => ProductCard(product: products[i]),
)

Also cap line length on wide screens. Text running the full width of a desktop window is genuinely hard to read — around 70 characters is the comfortable maximum.

Dart
Center(
  child: ConstrainedBox(
    constraints: const BoxConstraints(maxWidth: 720),
    child: articleBody,
  ),
)

Theming with Material 3

lib/theme/app_theme.dart
ThemeData buildTheme(Brightness brightness) {
  final colorScheme = ColorScheme.fromSeed(
    seedColor: const Color(0xFF0175C2),
    brightness: brightness,
  );

  return ThemeData(
    colorScheme: colorScheme,
    useMaterial3: true,
    textTheme: const TextTheme(
      titleLarge: TextStyle(fontWeight: FontWeight.w600, letterSpacing: -0.2),
    ),
    filledButtonTheme: FilledButtonThemeData(
      style: FilledButton.styleFrom(
        minimumSize: const Size.fromHeight(48),
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(12),
        ),
      ),
    ),
    inputDecorationTheme: InputDecorationTheme(
      filled: true,
      border: OutlineInputBorder(
        borderRadius: BorderRadius.circular(12),
        borderSide: BorderSide.none,
      ),
    ),
    cardTheme: CardThemeData(
      elevation: 0,
      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
    ),
  );
}

// Wire both variants and let the OS decide
MaterialApp(
  theme: buildTheme(Brightness.light),
  darkTheme: buildTheme(Brightness.dark),
  themeMode: settings.themeMode,
)

Set component styles in the theme, not on individual widgets. A FilledButton with a custom shape repeated in forty files is forty places to change; one filledButtonTheme is one.

Adaptive components

Dart
// These already adapt per platform — use them
const CircularProgressIndicator.adaptive();
Switch.adaptive(value: on, onChanged: setOn);

// Platform-appropriate dialog
showAdaptiveDialog<void>(
  context: context,
  builder: (context) => AlertDialog.adaptive(
    title: const Text('Sign out?'),
    actions: [/* ... */],
  ),
);

// Where behaviour genuinely differs, branch on the theme's platform
// (not dart:io, so it stays testable and works on web)
final platform = Theme.of(context).platform;
final useCupertinoStyle = platform == TargetPlatform.iOS ||
    platform == TargetPlatform.macOS;

Safe areas, insets and input

  • Wrap screen content in SafeArea so notches, status bars and home indicators do not clip your UI.
  • Use MediaQuery.viewInsetsOf(context).bottom to lift content above the keyboard, or Scaffold's resizeToAvoidBottomInset (on by default).
  • Touch targets must be at least 48×48 logical pixels. IconButton gives you this automatically; a bare GestureDetector around a 16px icon does not.
  • On desktop and web, support hover states, keyboard focus traversal and scroll wheels — FocusTraversalGroup and MouseRegion handle most of it.

Text scaling

Users can enlarge system text substantially. If your layout uses fixed-height boxes around text, it will overflow for those users — and this is one of the most common accessibility bugs in shipped Flutter apps.

Dart
// Test it: run the app and force a large scale factor
MediaQuery(
  data: MediaQuery.of(context).copyWith(
    textScaler: const TextScaler.linear(1.8),
  ),
  child: const MyScreen(),
)

// Prefer intrinsic sizing over fixed heights around text
// Bad:  SizedBox(height: 48, child: Text(label))
// Good: Padding(padding: EdgeInsets.symmetric(vertical: 12), child: Text(label))

Key takeaways

  • Branch on available width with LayoutBuilder, not on device type.
  • Use Material 3's 600/900 window size classes as your breakpoints.
  • Define component styles once in ThemeData and use colorScheme roles instead of literal colours.
  • Safe areas, 48px touch targets and large text scaling are correctness issues, not polish.

Practice

One app, three form factors

Take a list-detail screen and make it adaptive: bottom navigation with push-to-detail on phones, a navigation rail on medium widths, and a permanent side-by-side list-detail on expanded widths. Verify it at 200% text scale in both light and dark mode.

Show hints
  • On expanded layouts, keep the selected item in state rather than pushing a route.
  • Flexible with different flex factors gives you a natural 1:2 list-to-detail split.