13 min read

Flutter Layout from the Inside Out: Constraints, Stacks, and the Widgets That Keep You Sane

A deep-dive into Flutter's constraint model, core layout widgets, and the mental models that make complex UIs click — written from the perspective of a developer who learned it all in one intense sitting.

#flutter#dart#layout#mobile#widgets

Table of contents

  1. The One Rule That Governs Everything
  2. BoxConstraints: Tight, Loose, and Unbounded
  3. Tight constraints
  4. Loose constraints
  5. Unbounded constraints
  6. LayoutBuilder: Reading Your Constraints at Runtime
  7. The Supporting Cast: Four Widgets Worth Knowing
  8. ConstrainedBox
  9. UnconstrainedBox
  10. IntrinsicHeight and IntrinsicWidth
  11. Row and Column: Three Params to Own
  12. mainAxisSize
  13. mainAxisAlignment
  14. crossAxisAlignment
  15. Flexible, Expanded, and Spacer
  16. Expanded
  17. Flexible
  18. Spacer
  19. Stack: Thinking in Layers
  20. Sizing rule — the most important Stack fact
  21. Positioned vs Align
  22. Overflow clipping
  23. Wrap: The Layout That Thinks for Itself
  24. Flow — know it exists, don’t implement it today
  25. The Mental Model in One Paragraph

There is a moment in every airport — usually somewhere between security and the gate — where you realise the rules of normal life no longer apply. Liquids must be 100ml. Shoes may or may not need to come off. Your oversized carry-on is fine on one airline and a capital offence on the next.

Flutter’s layout system has the same energy.

You place a ListView inside a Column, hit run, and the app explodes with a wall of red. You add shrinkWrap: true and it works, but something feels wrong — like bribing a customs officer. It works, but you know it shouldn’t work that way.

This post is about learning the actual rules: not the workarounds, but the underlying model that makes every layout decision obvious once you understand it. We’ll cover the constraint system, LayoutBuilder, the core layout widgets (Row, Column, Stack, Wrap), and the supporting cast of ConstrainedBox, UnconstrainedBox, IntrinsicHeight, and IntrinsicWidth.

By the end, you won’t be guessing. You’ll know.


The One Rule That Governs Everything

Flutter’s layout is a three-step protocol, and it runs in strict order:

  1. Constraints go down — every parent passes a BoxConstraints object to each child
  2. Sizes come up — each child reports back the size it chose (within those constraints)
  3. Parent positions child — only after knowing all children’s sizes does the parent decide where to place each one

This is sometimes called the “constraint protocol” and it is the single most important mental model in Flutter. Everything else — every layout bug, every overflow, every mystery widget — traces back to a violation or misunderstanding of this protocol.

Key takeaway: A child cannot make itself larger than its maximum constraints. It cannot know its own position during build(). Both are decided by the parent, not the child.


BoxConstraints: Tight, Loose, and Unbounded

Every parent passes down a BoxConstraints object. Think of it as the packing rules your airline hands you before you check a bag. The bag (child) must fit within those rules — no exceptions.

// A BoxConstraints is just four numbers
BoxConstraints(
  minWidth:  0,
  maxWidth:  360,
  minHeight: 0,
  maxHeight: 640,
)

There are three flavours worth naming out loud:

Tight constraints

minWidth == maxWidth and minHeight == maxHeight. The child has exactly one valid size. No negotiation.

// SizedBox(width: 200, height: 100) passes this to its child:
BoxConstraints(
  minWidth:  200, maxWidth:  200,   // child WILL be 200px wide
  minHeight: 100, maxHeight: 100,   // child WILL be 100px tall
)

This is what SizedBox, Container with explicit dimensions, and the device screen itself all produce. The screen is always tight — your MaterialApp receives a tight constraint equal to the physical display size, and everything flows from there.

Loose constraints

minWidth == 0, maxWidth is finite. The child can be anywhere from zero to the maximum. It picks its own natural size.

// Center takes a tight constraint and passes a loose one to its child:
// parent gives Center: tight(360, 640)
// Center gives child:  loose → BoxConstraints(0..360, 0..640)

Center, Align, and Padding all loosen constraints this way. This is why Text inside a Center is only as wide as the text — it’s free to pick its natural size.

Unbounded constraints

maxHeight (or maxWidth) is double.infinity. The parent is saying: “take whatever space you need.” This is fine for widgets with intrinsic sizes (Text, Icon, Container with explicit dimensions). It is fatal for widgets that want to expand to fill space.

// Column passes this to each of its children:
BoxConstraints(
  minWidth:  0,
  maxWidth:  360,
  minHeight: 0,
  maxHeight: double.infinity,  // ← the crash source
)

This is why ListView inside a Column crashes. ListView asks: “what is my maximum height?” The answer is infinity. It tries to be infinitely tall. Flutter throws an error.

The fix is never to reach for shrinkWrap: true as a first instinct — that destroys virtualization and builds every item immediately. The correct fix is to make the constraint finite again:

// Fix A — Expanded: gives ListView the remaining finite Column height
Column(
  children: [
    Text('Header'),
    Expanded(
      child: ListView.builder(
        itemBuilder: (_, i) => ListTile(title: Text('Item $i')),
        itemCount: 20,
      ),
    ),
  ],
)

// Fix B — SizedBox: imposes a tight height directly
Column(
  children: [
    SizedBox(
      height: 300,
      child: ListView.builder(...),
    ),
  ],
)

LayoutBuilder: Reading Your Constraints at Runtime

Think of LayoutBuilder as the measuring tape you pull out before booking a hotel room. You don’t guess whether your suitcase will fit — you measure.

LayoutBuilder(
  builder: (BuildContext context, BoxConstraints constraints) {
    // constraints is what YOUR PARENT passed down to you
    debugPrint(constraints.toString());
    // → BoxConstraints(w=0..360, h=0..∞)  inside Column
    // → BoxConstraints(w=220.0, h=120.0)  inside SizedBox(220, 120)
    // → BoxConstraints(0..360, 0..640)    inside Center

    return Text(
      'maxH: ${constraints.maxHeight == double.infinity ? "∞" : constraints.maxHeight}',
    );
  },
)

The debugPrint line alone will teach you more in 10 minutes of experimentation than an hour of reading. Wrap different parents around a LayoutBuilder, tap different toggles, and read what comes out. Once you’ve seen double.infinity appear in the console, you never forget why ListView crashes inside Column.

LayoutBuilder is also the correct way to build responsive layouts — not MediaQuery for granular widget-level decisions:

LayoutBuilder(
  builder: (context, constraints) {
    // React to the space THIS WIDGET has, not the whole screen
    if (constraints.maxWidth < 400) {
      return const MobileLayout();
    }
    return const DesktopLayout();
  },
)

The Supporting Cast: Four Widgets Worth Knowing

ConstrainedBox

Adds constraints on top of whatever the parent passed. Think of it as a packing insert — it doesn’t replace the suitcase’s size limits, it adds inner partitions.

ConstrainedBox(
  constraints: const BoxConstraints(minWidth: 180, minHeight: 48),
  child: ElevatedButton(
    onPressed: () {},
    child: const Text('At least 180 × 48'),
  ),
)

The critical nuance: if the parent passes tight 80px wide, ConstrainedBox(minWidth: 100) cannot override it. Parent constraints always win. The child receives the intersection — the most restrictive combination of both.

UnconstrainedBox

Strips the parent’s constraints entirely, passing 0..∞ to its child. The child renders at its natural size, even if that means it overflows the parent’s boundary.

// Useful for: full-bleed banners inside padded columns,
// debugging what a widget's "natural" size actually is
UnconstrainedBox(
  child: Container(
    width: 340,  // wider than parent — would crash without UnconstrainedBox
    height: 40,
    color: Colors.blue.withOpacity(.15),
    child: const Center(child: Text('Full-bleed element')),
  ),
)

Use this deliberately, not as a workaround. If you’re using UnconstrainedBox to “fix” a layout, you probably have a constraint bug higher up the tree.

IntrinsicHeight and IntrinsicWidth

These solve a specific, common problem: “make all children in this Row the same height as the tallest one.”

IntrinsicHeight(
  child: Row(
    crossAxisAlignment: CrossAxisAlignment.stretch,
    children: [
      _Card('Short text'),
      _Card('Much\nlonger\ntext\nhere'),  // tallest
      _Card('Medium'),
      // all three will match this height ↑
    ],
  ),
)

The cost: Flutter performs a dry layout pass — it measures all children without painting them, finds the tallest, then re-lays out everything at that height. This is O(n²) in pathological cases. For a static row of 3–4 cards, invisible. For 50+ items in a ListView, a serious performance problem.

Rule of thumb: IntrinsicHeight and IntrinsicWidth are correct for small, static UI. Never put them inside a scrolling list.

IntrinsicWidth solves the same problem on the horizontal axis — useful for making a Column of buttons all match the width of the widest one without hardcoding a pixel value.


Row and Column: Three Params to Own

If BoxConstraints is passport control — the rules you can’t negotiate with — then Row and Column are the airline itself. They have their own policies about how space gets distributed.

mainAxisSize

Does the widget try to be as large as possible, or shrink to fit its children?

Row(mainAxisSize: MainAxisSize.max)  // fills all available width (default)
Row(mainAxisSize: MainAxisSize.min)  // shrinks to fit children exactly

This matters most for Column inside a Card or Container. Without mainAxisSize: MainAxisSize.min, the Column expands to fill all available height even if its content is three lines tall. The card becomes inexplicably enormous.

mainAxisAlignment

How leftover space is distributed along the main axis. Has no visible effect if there is no leftover space (i.e., if children fill the axis completely via Expanded).

Row(mainAxisAlignment: MainAxisAlignment.spaceBetween)
// ┌─────────────────────────────┐
// │ [A]         [B]         [C] │
// └─────────────────────────────┘

Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly)
// ┌─────────────────────────────┐
// │   [A]     [B]     [C]   │
// └─────────────────────────────┘

The interview trap: if every child is wrapped in Expanded, the Row has consumed all space. There is no leftover space to distribute. mainAxisAlignment becomes irrelevant.

crossAxisAlignment

Alignment on the perpendicular axis — vertical for a Row, horizontal for a Column.

Row(crossAxisAlignment: CrossAxisAlignment.stretch)
// Forces ALL children to match the tallest child's height.
// Same visual effect as IntrinsicHeight but without the extra layout pass —
// because Row already knows its own height before placing children.

CrossAxisAlignment.stretch is the performant alternative to IntrinsicHeight when the Row itself has a known height (e.g. it’s inside an Expanded or a SizedBox).


Flexible, Expanded, and Spacer

These three only work as direct children of Row or Column. Nesting them inside another widget and expecting them to affect the Row is a common mistake.

Expanded

Claims a share of the leftover space and forces the child to fill it completely.

Row(
  children: [
    Expanded(flex: 2, child: Container(color: Colors.blue)),   // 2/3 of width
    Expanded(flex: 1, child: Container(color: Colors.green)),  // 1/3 of width
  ],
)

Expanded is shorthand for Flexible(fit: FlexFit.tight). The fit: FlexFit.tight part means the child is forced to fill its allocated share, even if the child’s natural size would be smaller.

Flexible

Like Expanded but with fit: FlexFit.loose — the child can be smaller than its allocated share. It won’t overflow, but it won’t artificially expand either.

Row(
  children: [
    Flexible(child: Text('Short')),
    Flexible(child: Text('A considerably longer piece of text that might wrap')),
  ],
)
// Both Text widgets get equal share of space, but neither is forced to fill it.
// Text renders at its natural width, up to its maximum share.

The one-line rule: Expanded fills its share. Flexible uses up to its share.

Use Flexible when you have two text widgets of unknown length side by side and you want them to share space proportionally without either overflowing or having awkward whitespace.

Spacer

Spacer is Expanded(child: SizedBox.shrink()). An invisible box that eats space. Its only job is to push siblings apart.

Row(
  children: [
    const Text('Back'),
    const Spacer(),
    const Text('Next'),
  ],
)
// Back ←────────────────────────→ Next

This is cleaner than mainAxisAlignment: spaceBetween when you only want to push specific children apart, not distribute all children evenly.


Stack: Thinking in Layers

Every airport has a departures board — a single surface with layers of information painted on top of each other: flight numbers, gate numbers, status badges, all overlapping at precise positions. Stack is Flutter’s departures board.

Children are painted in order — first child is the bottom layer, last child is the top layer.

Stack(
  children: [
    // Layer 0: background (non-Positioned — sizes the Stack)
    Container(
      width: double.infinity,
      height: 200,
      decoration: BoxDecoration(
        image: DecorationImage(image: NetworkImage(url), fit: BoxFit.cover),
      ),
    ),

    // Layer 1: gradient scrim
    Positioned.fill(
      child: Container(
        decoration: BoxDecoration(
          gradient: LinearGradient(
            begin: Alignment.topCenter,
            end: Alignment.bottomCenter,
            colors: [Colors.transparent, Colors.black54],
          ),
        ),
      ),
    ),

    // Layer 2: title text, pinned to bottom-left
    Positioned(
      left: 16,
      bottom: 16,
      child: Text('Card Title',
          style: TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold)),
    ),

    // Layer 3: close button, pinned to top-right
    Positioned(
      top: 8,
      right: 8,
      child: IconButton(icon: Icon(Icons.close, color: Colors.white), onPressed: () {}),
    ),
  ],
)

Sizing rule — the most important Stack fact

Stack sizes to its largest non-Positioned child. Positioned children are invisible to the sizing calculation. If every child is Positioned, Stack collapses to zero size — a common gotcha.

Positioned vs Align

Positioned uses pixel offsets from the Stack’s edges. Align uses a proportional coordinate system.

// Align: (-1, -1) = top-left, (0, 0) = center, (1, 1) = bottom-right
Align(
  alignment: Alignment.bottomRight,
  child: Badge(...),
)

// Positioned: exact pixels from edges
Positioned(
  top: 12,
  right: 12,
  child: Badge(...),
)

Prefer Align when the Stack might change size (responsive layouts). Use Positioned when you need pixel-perfect placement at a known size (e.g. a fixed-height card).

Overflow clipping

Stack(
  clipBehavior: Clip.hardEdge,  // default — children clipped at Stack boundary
  // clipBehavior: Clip.none,   // children can overflow — useful for badges
  //                            // that deliberately peek outside a card edge
)

Wrap: The Layout That Thinks for Itself

A Row that hits the edge of the screen keeps going — and overflows. Wrap starts a new line. It’s the difference between a very long airport queue (Row, everyone in one line regardless of space) and a waiting area with rope dividers (Wrap, new rows form automatically).

Wrap(
  spacing: 8,       // horizontal gap between children in the same line
  runSpacing: 8,    // vertical gap between lines
  alignment: WrapAlignment.start,
  children: [
    'Flutter', 'Dart', 'Widgets', 'Layout', 'Constraints',
    'Stack', 'Row', 'Column', 'Wrap', 'Expanded',
  ].map((tag) => Chip(label: Text(tag))).toList(),
)

spacing is the gap within a line. runSpacing is the gap between lines. These two params cover every tag cloud, chip group, or filter bar you will ever build.

WrapAlignment controls how children are distributed within each line — same values as MainAxisAlignment (start, center, end, spaceBetween, etc.).

Flow — know it exists, don’t implement it today

Flow is what you reach for when Wrap isn’t enough: radial menus, fan animations, custom tab bars with non-linear placement. It gives you a paintChild() callback where you position each child manually at any offset. Its performance advantage over Stack is that Flutter skips the layout pass on repaint — it only calls paintChild again, not a full rebuild. This makes it faster for animated layouts where children are moving every frame.

You almost certainly won’t need to implement a FlowDelegate in an interview. Know that it exists, know why it’s faster than a dynamic Stack, and move on.


The Mental Model in One Paragraph

Flutter layout is a conversation. The parent speaks first, setting the rules (constraints). The child listens, picks a size within those rules, and reports back. The parent then decides where to place the child — not before. A child that needs to be bigger than its parent allows is a child that needs a different parent, not a different child. Every layout bug is either a parent passing the wrong constraints, a child not understanding what constraints it received, or something in between trying to subvert the conversation. Fix the conversation, not the symptom.

That’s the model. Everything else is just vocabulary.


All code in this post was written and tested against Flutter 3.x with Dart 3. The playground app used throughout is a single main.dart file with each concept in its own self-contained widget, dropped into a scrollable ListView — a pattern that scales cleanly as you add new sections without touching existing code.

Share

More to explore

Keep exploring

Next

Kotlin Language: Variables & Types Refresher