DEV Community

Cover image for Building a Responsive Flutter UI That Never Breaks
Gulshan Yadav
Gulshan Yadav

Posted on Originally published at misar.blog

Building a Responsive Flutter UI That Never Breaks

LayoutBuilder, breakpoints, and adaptive widgets — a system that survives every screen size, including the ones you didn't test.

The first Flutter app I shipped looked perfect — in the Android Studio emulator, at one fixed resolution. The first person to install it on a real phone had a narrow device, and half the dashboard overflowed with the classic yellow-and-black striped error bars. I fixed that bug, and then the next overflow, and the next, every time someone rotated the screen or resized the window.

So, in this article, I will be showing you how I build responsive Flutter UIs now — the ones that do not break on any screen, orientation, or text scale, because I stopped guessing sizes and started building a small, boring system.

The good news first: you need zero extra dependencies. Responsiveness in Flutter is built into the framework. What you need is the discipline to use the right tools in the right places. Let me show you exactly which tools, in order.

The Rule: Let Constraints Drive, Never Absolute Pixels

The single habit that fixes most breakage: stop using absolute sizes for anything that can vary. No SizedBox(width: 400), no hard-coded card widths, no fontSize: 20 on body text that needs to scale with the user's accessibility settings. If a size depends on the screen, the screen should decide — and Flutter gives you exactly three ways to let it.

Step 1: LayoutBuilder — Size Comes From Your Parent

LayoutBuilder gives you the constraints your parent actually imposes, at build time. This is the workhorse of responsive layout. Here is the breakpoint switch I use in almost every screen:

class ResponsiveScaffold extends StatelessWidget {
  const ResponsiveScaffold({super.key});

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        final width = constraints.maxWidth;

        if (width >= 1200) {
          return const WideLayout();      // desktop
        } else if (width >= 700) {
          return const MediumLayout();    // tablet / landscape
        } else {
          return const NarrowLayout();    // phone
        }
      },
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Three layouts, one decision point. When you open this file, you can see the whole responsive story of the screen at a glance. The breakpoints (1200 / 700) are not magic numbers — they are where your layout actually stops working, and you should tune them to your design, not to a blog post.

Inside a layout, use the same pattern at a finer grain. A detail panel that should sit beside content on wide screens and below it on narrow ones:

LayoutBuilder(
  builder: (context, constraints) {
    final showSidebar = constraints.maxWidth >= 900;
    return Row(
      children: [
        Expanded(child: content),
        if (showSidebar) SizedBox(width: 320, child: sidebar),
      ],
    );
  },
)
Enter fullscreen mode Exit fullscreen mode

Step 2: Flexible and Expanded — Sharing Space, Not Fighting Over It

Half the overflows I debugged came from fixed-size children inside flexible parents. Expanded and Flexible are how you tell a child, "take the space the parent decided to give you, and make it work." The rule I live by: a Row or Column of dynamic content should have exactly one child that can't be squeezed — and the rest should be flexible.

The distinction between the two matters more than people think. Expanded is a Flexible with fit: FlexFit.tight — the child must fill the assigned space, even if that means being stretched past its natural size. Flexible with FlexFit.loose gives the child the space as an upper bound: it can take less if it does not need more. For a title row you almost always want Flexible on the text side and Expanded only when you genuinely need the child to fill every pixel. Text widgets also get an explicit flex factor when you want them to share proportionally — a Flexible(flex: 2) and a Flexible(flex: 1) split leftover space 2:1, which is how I do two-column info rows without any fixed widths.

Row(
  children: [
    const CircleAvatar(radius: 24),
    const SizedBox(width: 12),
    Expanded(
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(title, maxLines: 1, overflow: TextOverflow.ellipsis),
          Text(subtitle, maxLines: 2, overflow: TextOverflow.ellipsis),
        ],
      ),
    ),
    if (trailing != null) trailing!,
  ],
)
Enter fullscreen mode Exit fullscreen mode

Notice the details that keep this from breaking: the titles are capped with maxLines and TextOverflow.ellipsis, so a long name or a large font scale cannot push the row out of bounds. Expanded takes the remaining width; the trailing widget keeps its natural size. If you leave every child unconstrained and just hope, the text will win and the row will break.

For grids and lists, the same idea: GridView with SliverGridDelegateWithMaxCrossAxisExtent lets the number of columns be decided by available width instead of by you:

GridView.builder(
  gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
    maxCrossAxisExtent: 260,
    childAspectRatio: 1.2,
  ),
  itemBuilder: (context, index) => ProductCard(index: index),
)
Enter fullscreen mode Exit fullscreen mode

On a phone you get 2 columns, on a tablet 4, on a desktop 6 — and you wrote one line. The framework counts columns for you.

Step 3: MediaQuery — The App-Level Facts

LayoutBuilder tells you about space. MediaQuery tells you about the device — orientation, text scale, platform, and safe areas. Use it for decisions that are about the environment, not the box:

final mq = MediaQuery.of(context);

if (mq.orientation == Orientation.landscape && mq.size.height < 500) {
  return const LandscapeCompact();   // desktop-style layout on a phone turned sideways
}
Enter fullscreen mode Exit fullscreen mode

And the one I add to every scaffold: SafeArea. The modern phone cutout, the home indicator, and the notch will eat your content on real devices even though the emulator never shows them. Wrap anything that can sit near the edge:

return SafeArea(child: Scaffold(body: content));
Enter fullscreen mode Exit fullscreen mode

For the orientation decision specifically, OrientationBuilder is sometimes the cleaner tool than reading MediaQuery — it rebuilds exactly when the orientation flips, rather than on every size change, which keeps it cheap:

OrientationBuilder(
  builder: (context, orientation) {
    if (orientation == Orientation.portrait) {
      return const Column(children: [SettingsList(), Controls()]);
    }
    return const Row(children: [Expanded(child: SettingsList()), Controls()]);
  },
)
Enter fullscreen mode Exit fullscreen mode

Use OrientationBuilder when your layout decision is literally "portrait vs landscape." Use LayoutBuilder when the actual width or height numbers matter. The two overlap, but they answer different questions, and mixing them thoughtfully is what separates a responsive app from a fragile one.

Step 4: Adaptive Widgets — Same Logic, Different Widget

Responsive is not just about size; it is about choosing the right widget for the platform and context. Flutter's material library gives you widgets that pick for you. SelectableRegion aside, the ones I reach for daily:

  • NavigationRail vs NavigationBar — a rail on wide screens, a bottom bar on phones. The adaptive variants handle the switch.
  • Dialog vs AlertDialog — trivial, but the adaptive dialog picks the platform style.
  • ListView vs Wrap — when item counts are unknown, a Wrap flows instead of overflowing.

Here is the pattern I actually ship — a single AdaptiveProductGrid that switches layout based on width using only core widgets:

class AdaptiveProductGrid extends StatelessWidget {
  const AdaptiveProductGrid({super.key});

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        final isWide = constraints.maxWidth >= 700;

        return Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text('Featured',
                style: isWide
                    ? Theme.of(context).textTheme.headlineMedium
                    : Theme.of(context).textTheme.titleLarge),
            if (isWide)
              Row(
                children: [
                  Expanded(child: _Card()),
                  const SizedBox(width: 16),
                  Expanded(child: _Card()),
                  const SizedBox(width: 16),
                  Expanded(child: _Card()),
                ],
              )
            else
              ListView.separated(
                shrinkWrap: true,
                physics: const NeverScrollableScrollPhysics(),
                itemCount: 3,
                separatorBuilder: (_, __) => const SizedBox(height: 12),
                itemBuilder: (_, i) => _Card(),
              ),
          ],
        );
      },
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

One screen, two arrangements, driven entirely by the parent's constraints. This is the whole method.

Important Notes and Pitfalls

These are the traps I hit so you do not have to:

  1. MediaQuery.of(context) used for layout decisions deep in the tree is fine, but for per-widget sizing always prefer LayoutBuilder — the media query tells you about the whole screen, not the space your widget actually has inside a column.
  2. Hard-coded heights still break everything. If a card has height: 240 and the text inside scales up for an accessibility setting, you get overflow. Use IntrinsicHeight sparingly (it is expensive) or let content define height with flex containers.
  3. Rotating the phone is a different layout pass. Test landscape — a phone rotated is narrower in height than a desktop, and a wide row that worked in portrait will overflow in landscape.
  4. Text scaling is the silent killer. Users with large font settings will break your carefully measured UI even though every widget is "flexible." Always cap text with maxLines + overflow, and never hard-code fontSize on text that must scale — use MediaQuery.textScaler or the theme.
  5. Sizes inside an Expanded are still fixed. Expanded gives the child a width, but the child can still overflow vertically if you set a fixed height and the content is taller. The pair that works is flex on the axis you want to survive and clip/scroll on the other.
  6. Test on the smallest device you plan to support, not the prettiest. If the breakpoint logic is right, the small phone is where it proves itself.

The Questions I Get Every Time

"Do I need a package like responsive_framework?" No. For 90% of apps, the core toolkit above is enough, and every package you add is a constraint you will fight later. Packages help when you want media-query-style breakpoint helpers across a huge app; by the time you need that, you also know exactly why, and you can evaluate them on their merits. Start dependency-free.

"Where should the breakpoint numbers live?" One constants file. Put 1200, 900, 700 in named constants like kTabletBreakpoint and kDesktopBreakpoint, with a comment explaining what breaks at each width. Magic numbers scattered across 40 widgets are how "we tuned it once" becomes "we tuned it forty times, inconsistently."

"What about very wide desktop windows?" Cap the content, do not stretch it. A ConstrainedBox with maxWidth: 1200 centered on screen reads far better than a dashboard that spans 2,500 pixels of ultrawide. Wider is not better past the point where the eye has to travel too far.

"How do I handle tablets with weird aspect ratios?" Same as everything else — by width, not by device model. Foldables, split-screen multitasking, and desktop windows all produce arbitrary widths, and a width-driven layout handles all of them with the same three branches.

The Method, Compressed

Here is the decision rule I hand to anyone on my team, in order of preference:

  1. Let the parent decide. LayoutBuilder at the top of the screen; choose a layout from the available width.
  2. Never fight the space. Expanded / Flexible for the dynamic content; cap text with maxLines and ellipsis.
  3. Ask the device for facts. MediaQuery for orientation and text scale; SafeArea for real-device edges.
  4. Let the framework count. SliverGridDelegateWithMaxCrossAxisExtent and adaptive material widgets instead of manual math.
  5. Prove it. Run the screen at every breakpoint width, both orientations, and the largest text scale your OS offers — that ten-minute pass catches the regressions.

Done that way, "responsive" stops being a heroic refactor and becomes the default. No overflows, no fixed-size assumptions, no surprises when someone installs the app on a phone you never owned.

I have also written about adaptive navigation and platform-adaptive theming — comment below with your own responsive horror story, or the screen that keeps breaking for you, and I will cover it next.


*Gulshan Yad

Top comments (1)

Collapse
 
crdtcto profile image
Kane Lim

Hello Glad to see you, I am Kane Lim from Hong Kong. I have over 10 years of development experience. I am writing this because your post was interesting.

This is exactly the right mindset for Flutter responsiveness. The deeper issue is not responsive widgets, but treating constraints as part of the architecture rather than presentation details.

I would take your approach further by making breakpoints semantic and component driven. Instead of scattering width checks, define layout capabilities such as compact, medium, expanded and let individual components declare their minimum viable constraints. This works especially well with nested LayoutBuilder instances and avoids coupling widgets to device categories.

For complex applications, I also recommend golden tests across breakpoint boundaries combined with automated text scaling tests. Test widths immediately below and above every breakpoint, plus extreme font scaling and localization with long strings. That catches constraint regressions that emulator testing often misses.

Your point about designing for arbitrary widths is particularly important for foldables, desktop resizing and split screen. Responsive UI becomes much more reliable when the layout responds to constraints rather than device identity.

Excellent practical guide. I would enjoy discussing scalable Flutter design systems and adaptive architecture with you.