# 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:

```dart
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
        }
      },
    );
  }
}
```

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:

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

## 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.

```dart
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!,
  ],
)
```

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:

```dart
GridView.builder(
  gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
    maxCrossAxisExtent: 260,
    childAspectRatio: 1.2,
  ),
  itemBuilder: (context, index) => ProductCard(index: index),
)
```

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:

```dart
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
}
```

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:

```dart
return SafeArea(child: Scaffold(body: content));
```

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:

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

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:

```dart
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(),
              ),
          ],
        );
      },
    );
  }
}
```

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.

---

*Originally published at [https://www.misar.blog/@mrgulshanyadav/articles/building-a-responsive-flutter-ui-that-never-breaks](https://www.misar.blog/@mrgulshanyadav/articles/building-a-responsive-flutter-ui-that-never-breaks)*

