Global Responsive Design in Flutter - A Layered Setup That Covers the In-Between Sizes
Intro
While building a Flutter app across tablets, foldables, and both orientations, I kept finding that screens I never explicitly designed for fell apart. You can’t QA every possible size, but a size you didn’t check shouldn’t be allowed to wreck the UI either. I needed a responsive setup better than the one I had.
This is a write-up of the layer structure and patterns I settled on for a flutter_screenutil-based app — and the traps I hit along the way.
Two branches of responsive
Start with the big picture. When the screen grows, there are only two things you can do.
| scale | adapt | |
|---|---|---|
| how | stretch one design proportionally | change the layout structure itself |
| result | bigger | more (two-pane, grid, master-detail) |
| representative | flutter_screenutil | LayoutBuilder, breakpoints, flutter_adaptive_scaffold |
| upside | pixel-perfect reproduction of one design, cheap | uses the space properly, the native standard |
| limit | just gets bigger on large screens | design and implementation cost |
The two aren’t mutually exclusive. Usually you let adapt handle the coarse steps and scale handle the continuous change within each step. Adapt decides which design to use (phone? tablet?), and scale decides how much to stretch that design.
The layers
A global responsive setup roughly stacks like this:
1
2
3
4
5
Layer 0. device classification - which device/orientation are we in (profile)
Layer 1. scale baseline - relative to what, by how much (designSize)
Layer 2. content width - how wide do we let it get (maxWidth cap)
Layer 3. scale <-> width link - make scale respect the cap (.wc)
Layer 4. text - how far to defend
Layer 0 is the adapt axis (picking which design to use); layers 1–4 catch the problems that come from stretching that chosen design with scale. We’ll work down through them, looking at the pattern and the trap in each.
Layer 0. Device classification - profiles
Split the axes, keep the decision in one place
Device classification splits along two axes. Multiply device class by orientation to get a profile.
1
device class (phone/tablet) × orientation (portrait/landscape) = 4 profiles
1
2
3
4
5
6
7
8
9
10
11
12
13
enum AppDesignProfile { phonePortrait, phoneLandscape, tabletPortrait, tabletLandscape }
static AppDesignProfile designProfileForWindow(Size size) {
final isTablet = size.shortestSide >= 600; // (1) device tier
if (!isTablet) {
return size.width > size.height // (2) orientation
? AppDesignProfile.phoneLandscape
: AppDesignProfile.phonePortrait;
}
return size.width > size.height
? AppDesignProfile.tabletLandscape
: AppDesignProfile.tabletPortrait;
}
- 600 isn’t arbitrary — it’s the standard line from Android’s
sw600dp/ the Material window size classes. - Using
shortestSidemeans rotating a device flips only the orientation, never phone↔tablet.
The point of the pattern is to trap the decision in one place. The whole app calls a single AppBreakpoints.designProfile(context), and every other piece of code branches purely on the profile enum. The moment checks like MediaQuery.of(context).size.width > 600 scatter across the codebase, changing the threshold means chasing them everywhere.
When to add a profile
Start with four, and add a middle tier (e.g. Material’s medium, 600–840) only when a real device actually looks off. Adding a profile isn’t free — you have to revisit every branch that switches on it.
Layer 1. Scale baseline - designSize and its guardrails
A baseline per profile
ScreenUtil scales every value by “the actual screen size relative to a design baseline.”
| unit | factor | use |
|---|---|---|
.w | screenWidth / designWidth | width, horizontal distance |
.h | screenHeight / designHeight | height, vertical distance |
.r | min(scaleW, scaleH) | squares, icons, radius |
.sp | min(scaleW, scaleH) when minTextAdapt: true | fonts |
Keep the baseline per profile.
1
2
3
4
phonePortrait = Size(360, 738)
phoneLandscape = Size(738, 360)
tabletPortrait = Size(680, 1024)
tabletLandscape = Size(1024, 680)
This way the factor stays close to 1 on each profile’s representative device, so things come out as designed.
Trap: when the window aspect is far from the baseline, one axis blows up
Resizing the window in desktop mode broke the UI.
1
2
portrait window 592×765 → scaleW 1.65 / scaleH 1.04 → only width grows
landscape window 575×556 → scaleW 0.78 / scaleH 1.55 → only height grows + text shrinks
The cause was clear. The baseline aspect is either 0.49 (tall) or 2.05 (wide), but a resized window sits near 1:1 — close to neither. On real devices (phone ~0.46, tablet ~1.6) the aspect is near the baseline so it never showed, but an arbitrary window size set it off. And it’s not just resizable windows: a Galaxy Z Fold or Flip produces all kinds of aspect ratios depending on its folded state (the Fold’s inner screen is near-square, the Flip’s cover is tall and narrow).
If you can’t fix it directly, fix the denominator
You can’t clamp .w itself — it’s a global singleton, and a num extension. So instead you adjust designSize, the denominator of the factor, to pin the resulting factor into a band.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static const double minDesignScale = 1.0; // never shrink below the design baseline
static const double maxDesignScale = 1.25; // at most 1.25×
static Size designSizeForWindow(Size size) {
final base = baseDesignSizeForWindow(size); // the fixed per-profile baseline
return Size(
_clampDesignDim(size.width, base.width),
_clampDesignDim(size.height, base.height),
);
}
static double _clampDesignDim(double window, double baseDesign) {
if (window <= 0 || baseDesign <= 0) return baseDesign;
final scale = (window / baseDesign).clamp(minDesignScale, maxDesignScale).toDouble();
return window / scale; // when the factor leaves the band, adjust designSize to offset it
}
| window | before | after (band 1.0~1.25) |
|---|---|---|
| portrait 592×765 | design 360×738 / scaleW 1.65 / scaleH 1.04 | design 473.6×738 / scaleW 1.25 / scaleH 1.04 |
| landscape 575×556 | design 738×360 / scaleW 0.78 / scaleH 1.55 | design 575×444.8 / scaleW 1.00 / scaleH 1.25 |
| ordinary phone/tablet | factor ≈ 1.0~1.25 | unchanged |
The reason this pattern is powerful: .w/.h/.r/.sp all derive from designSize. Adjust the single denominator and every scaled value passes through the clamp automatically. Not one call site changes.
Generalize: when a derived value is out of your reach, fix the input it’s computed from. That’s almost always cheaper than forking the library or touching hundreds of call sites.
The 1.0 floor isn’t free
The 1.25 ceiling is cosmetic — “don’t get too big.” The floor is a different animal. A factor below 1 means the window is narrower than the canvas, and you get one of two outcomes: shrink (text and touch targets get smaller) or overflow (content spills). Overflow is absorbed by Flexible, wrap, and scrolling, but mangled body text and touch targets under 40dp can’t be recovered. The 1.0 floor chooses the recoverable failure and blocks the unrecoverable one. That’s also why the band isn’t symmetric like [0.8, 1.2].
But this is a decision coupled to the baseline. The phonePortrait baseline of 360 is effectively the minimum width of a current phone, so the only windows that hit the floor are split-screen slivers or resized windows. Had you set the baseline to 414 (a large phone), every 360 phone would hit the floor and overflow routinely. Put the baseline at the bottom of a profile’s range and the 1.0 floor is nearly free; put it in the middle and it gets expensive.
Layer 2. Content width - maxWidth cap
Why it’s needed
With scale alone, a single button becomes 800px on a tablet. Clamping the factor in layer 1 doesn’t help here: a full-width element laid out with double.infinity or Expanded follows the window width regardless of scale. So you constrain content width and center it — a max-width container.
1
2
3
4
5
6
double get contentMaxWidth => switch (this) {
AppDesignProfile.phonePortrait => double.infinity, // cap is meaningless on phones
AppDesignProfile.phoneLandscape => 600,
AppDesignProfile.tabletPortrait => 800,
AppDesignProfile.tabletLandscape => 800,
};
Expressing “no cap” as double.infinity turns it off naturally, without a branch.
A single injection point
Apply the cap in one shared scaffold, not per page.
- When the cap is finite, center the entire scaffold — appbar included — with
Align(topCenter) + ConstrainedBox(maxWidth). Capping only the body and leaving the appbar full-width makes the left/right edges disagree. - Keep the scaffold background transparent and lay a full-screen background layer behind it. Otherwise the narrowed side gutters show up blank.
- When the cap is
∞, skip the wrapper entirely and return the original Scaffold — layout unchanged.
With one injection point, every page adapts for free, and changing the policy means editing one place.
Trap: the drawer gets trapped too
Capping the whole scaffold also traps its endDrawer. The scrim only covers the narrow column, and the drawer slides in from the column’s edge instead of the screen’s edge.
The fix is to put one more full-screen Scaffold on the outside and move the drawer there. The inner scaffold stays transparent with no drawer, and the appbar button opens the outer drawer via a GlobalKey<ScaffoldState>.
Layer 3. Linking scale and width - .wc
Even with a cap in place, scale knows nothing about it.
.w has no idea about the container
1
2
3
4
// inside flutter_screenutil
extension SizeExtension on num {
double get w => ScreenUtil().setWidth(this); // global singleton, no BuildContext
}
.w reads the device’s full window width from a global singleton. It has no way to know about a ConstrainedBox in the widget tree. So even when you pin content to 600:
| device width | content box | scaleW (raw → clamp) | actual 20.w |
|---|---|---|---|
| 1024 | 600 (cap) | 1.0 → 1.0 | 20 |
| 1280 | 600 (cap) | 1.25 → 1.25 | ≈ 25 |
| 1600 | 600 (cap) | 1.56 → 1.25 | ≈ 25 (≈31 without clamp) |
The box stopped, but the padding inside it keeps growing with the screen. Layer 1’s clamp stops the runaway (it holds at 25 even at 1600), but from the perspective of a box pinned to 600 that’s still a value off by 25%. The clamp preserves the factor relative to the screen — it isn’t a mechanism that understands the container.
A correction utility that degrades gracefully
Since you can’t fix the library, you add one more horizontal scale, scoped to the cap region. The formula is simple.
1
2
k = min(screenWidth, maxWidth) / screenWidth // <= 1
wc = value.w × k
In numbers, with design 1024 and cap 600 (ignoring layer 1’s clamp, just .wc on its own):
| screen width | scaleW | 20.w | k | 20.wc |
|---|---|---|---|---|
| 500 | 0.49 | 9.8 | 1.0 | 9.8 (= .w) |
| 600 (= cap) | 0.59 | 11.7 | 1.0 | 11.7 (= .w) |
| 1024 | 1.0 | 20 | 0.59 | 11.7 |
| 1280 | 1.25 | 25 | 0.47 | 11.7 |
.w tracks the screen width; .wc tracks the box width. Below the cap it’s identical to .w, and the moment it crosses the cap it freezes at that value.
Layer on layer 1’s clamp (band 1.0~1.25) and it becomes this:
| screen width | scaleW (raw → clamp) | 20.w | k | 20.wc |
|---|---|---|---|---|
| 500 | 0.49 → 1.0 (floor) | 20 | 1.0 | 20 (= .w) |
| 600 (= cap) | 0.59 → 1.0 (floor) | 20 | 1.0 | 20 (= .w) |
| 1024 | 1.0 | 20 | 0.59 | 11.7 |
| 1280 | 1.25 | 25 | 0.47 | 11.7 |
Above the cap it’s the same; only below the cap does it differ. Because the floor honors “never shrink below the design baseline,” a value that would otherwise collapse to 9.8 gets propped up to 20.
So as the screen grows, there’s a stretch where .wc drops from 20 to 11.7. That’s convergence, not degradation. The value that preserves the mockup ratio (20/1024 = 1.95%) inside a 600 box is 11.7; the 20 is an exception the floor propped up for readability. As the screen grows, the floor lets go and the value settles back to where it belongs. It’s a trade-off: you keep the absolute size on small windows at the cost of consistent spacing across devices.
This is what happens when two mechanisms each do their own job without knowing about the other. More on that in “The two clamps are orthogonal” below.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class ContentWidthScope extends InheritedWidget {
final double widthFactor; // k
static double factorFor({required double screenWidth, required double maxWidth}) {
if (screenWidth <= 0 || maxWidth <= 0) return 1.0;
return math.min(screenWidth, maxWidth) / screenWidth;
}
static double factorOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<ContentWidthScope>()?.widthFactor ?? 1.0;
@override
bool updateShouldNotify(ContentWidthScope old) => old.widthFactor != widthFactor;
}
extension CappedWidthExt on num {
double wc(BuildContext context) => w * ContentWidthScope.factorOf(context);
}
The truly important part of this pattern isn’t the formula — it’s the ?? 1.0.
- No cap, or a screen narrower than the cap →
k = 1→.wc == .w - Called outside the scope →
k = 1→.wc == .w
That is, it has a default that behaves exactly like the existing code. So you can roll it out globally and nothing changes on phones/portrait, and you can reach for it in shared widgets without worry.
Generalize: when introducing a new global utility, design the safe degrade first. If “identical to the old behavior where it isn’t applied” is guaranteed, you can migrate incrementally without a full sweep. Without that, global adoption becomes all-or-nothing — and then nobody does it.
Trap: the context gate
.wc(context) reads an InheritedWidget, so it must be called from a context below the scope.
1
2
3
4
5
Widget build(BuildContext context) {
// ❌ this context is above the scope → reads as k=1, cap doesn't apply (and no error!)
final padding = EdgeInsets.symmetric(horizontal: 20.wc(context));
return SomeScaffold(child: Column(children: [Padding(padding: padding, ...)]));
}
dependOnInheritedWidgetOfExactType only walks upward from the given context. But the thing that installs the scope is SomeScaffold — inside the tree this build returns. Relative to the call site, the scope is a descendant, not an ancestor, so the walk never reaches it.
1
2
3
4
build's context ← .wc called here. Walking up finds no scope → k = 1
└ SomeScaffold
└ ContentWidthScope ← the scope sits below the call site
└ Column ...
1
2
3
4
5
// ✅ grab a lower context with Builder
child: Builder(builder: (context) {
final padding = EdgeInsets.symmetric(horizontal: 20.wc(context));
return Column(children: [Padding(padding: padding, ...)]);
}),
Builder creates a fresh context at its own spot. That context is below the scope, so walking up finds it.
The worst part is that it fails silently. No error is thrown, so you burn time on “why isn’t the cap applying?” The ?? 1.0 we just praised is the culprit. Making it throw when the scope is missing would have prevented this trap, but then you couldn’t use it in shared widgets. The silence is the price of the safe degrade. Sticking to the principle “pass raw values down and apply scaling in the final widget” avoids it naturally.
Why there’s no .hc
A cap is fundamentally a width limit. .h is measured against screen height, unrelated to maxWidth, and “height exceeded the cap” isn’t even a situation that exists. It’s conceptually unnecessary.
The two clamps are orthogonal
By now there are two things that “cut off.” They’re separate mechanisms solving different problems.
| ① designSize clamp | ② .wc factor k | |
|---|---|---|
| problem it solves | factor runaway when window aspect is far from the baseline | horizontal not respecting the content maxWidth cap |
| basis | window / designBaseline, banded | min(screen, cap) / screen |
| axis | both width and height | width only |
| reach | app-wide (.w/.h/.r/.sp all) | below the scope, only where .wc is used |
| implementation | adjust designSize (the denominator) | multiply into the .w result |
They aren’t alternatives — they stack in order.
1
2
3
4
5
6
7
① designSize clamp → scaleW pinned to [1.0, 1.25]
↓
.w = value × scaleW ← already-clamped factor
↓
② .wc = .w × k ← cap correction, once more
↓
final = value × clampedScaleW × k
① is the global safeguard that keeps the factor from going wild; ② is the local correction that makes width respect the cap. The problems they solve are orthogonal, so neither one alone solves the other.
But orthogonal is the design, not the effect. The final value is the product of the two factors, so they interfere. When ① pins the factor to the band, there’s no longer a term for ② to offset — and the .wc 20 → 11.7 we saw earlier is the fingerprint of exactly that. The cost of designing them to be unaware of each other shows up where they multiply.
Layer 4. Text - global vs local defense
.sp, like .w, doesn’t know about the cap. So should you build .spc too? In most cases, better not to.
- Readability is measured against the screen, not the container. Just because the box stopped at 600 doesn’t mean the text should shrink too — on a large screen that just feels cramped. The web caps container width but scales fonts independently.
minTextAdapt: trueis already conservative. The.spfactor ismin(scaleW, scaleH), so no matter how wide the width gets, the height acts as the ceiling.- The plumbing cost is high.
.spusually sits inside a style function as16.sp, with no context. Introducing.spc(context)means threading context through every one of those signatures.
So does it break? — the breakage is confined
| situation | result |
|---|---|
| wrapping text grows | just more lines → scrolling absorbs it, no break |
| text in a fixed-height box | possible vertical clipping |
a rigid Row (no Flexible) | horizontal overflow |
Local defense over global defense. A global mechanism is only justified when the breakage is structurally unavoidable. Text isn’t that case — the breaking shape is well-defined, so guard just there with
Flexible/ellipsis.
Dev environment tips
1) An environment you can freely resize
Checking the four profiles by rotation alone misses the boundaries and the middle ground. You need to freely grow and shrink the window before something like a “near-square window” reveals itself.
An Android tablet’s desktop mode (Samsung DeX and the like) is handy — you can float the app in a window and vary its size freely.
2) A debug overlay
Once the environment surfaces a problem, the next step is the cause. Debugging is much easier when you can see, at a glance, which profile you’re in and what the factors are.
1
2
3
4
5
TABLET · landscape
1227.6 × 984.0 dp
short 984.0 · dpr 1.38
cap 800 · design tabletLandscape
scaleW 1.20 · scaleH 1.25
Make it depend on MediaQuery and it refreshes on every window resize. As you drag, you can watch the profile flip and the factor hit the band in real time.
Wrap-up
- Responsive has two axes: adapt (which design) and scale (how much to stretch). They aren’t exclusive — you usually split into profiles first, then fill each one in with scale.
- Trap the device decision in one place. The rest of the code only sees the profile enum. And it’s a window, not a device.
- The factor blows up when the window aspect is far from the baseline. If you can’t fix
.w, fix designSize, the denominator — every derived value follows automatically. - Apply the cap in one shared scaffold. Anything that has to be full-screen, like a drawer, must live outside the wrapping layer.
.wdoesn’t know about the cap. Correct it with.wc, but the?? 1.0(safe degrade) is the key — that’s what lets you migrate incrementally.- There are two clamps, and they’re orthogonal: ① is the global factor safeguard, ② is the in-cap width correction.
- For text, don’t build a global mechanism — defend only the breaking shape (the rigid Row) locally.