Skip to content
Blog

LayoutBuilder for Responsive UI in Flutter

Use LayoutBuilder's BoxConstraints to build adaptive Flutter layouts — a breakpoints pattern, LayoutBuilder vs MediaQuery, and how to avoid unbounded-constraint gotchas.

Published on • October 29, 2026

AI Assistant

A Flutter app that looks great on your phone can look broken on a tablet: three cramped columns, a sidebar squeezed into 200 pixels, or a dashboard that refuses to use the extra space. The fix isn’t more MediaQuery.of(context).size checks sprinkled everywhere — it’s LayoutBuilder, the widget that hands you the exact constraints of the space your widget is about to occupy.

In this tutorial, you will learn how LayoutBuilder exposes BoxConstraints at layout time, how to turn that into a clean breakpoints pattern, when to prefer it over MediaQuery, and how to dodge the classic unbounded-constraints traps inside rows and scrollable lists.

What LayoutBuilder actually gives you

The API is one callback:

LayoutBuilder(
  builder: (BuildContext context, BoxConstraints constraints) {
    // Use constraints.maxWidth, constraints.minHeight, etc.
    return const SizedBox.shrink();
  },
)

According to the Flutter API docs, LayoutBuilder “builds a widget tree that can depend on the parent widget’s size”. It’s similar to Builder, except the framework calls the builder function at layout time and passes in the parent widget’s constraints. Two details matter in practice:

  • The builder is called the first time the widget is laid out, when the parent passes different constraints, and when an inherited dependency changes — not on every rebuild if the constraints are the same.
  • The LayoutBuilder’s final size matches its child’s size.

Because the callback runs during layout rather than during build, the returned widget subtree can depend on real available space — which is exactly what a responsive layout needs.

LayoutBuilder vs MediaQuery: two different questions

Flutter’s adaptive and responsive design docs draw a useful line: responsive design is about fitting the UI into the available space, while adaptive design is about the UI being usable in that space (should a tablet use side-panel navigation instead of bottom navigation?).

  • MediaQuery answers “what is the state of the device?” — screen size, orientation, padding, platform brightness, text scale. It reflects the whole window or screen.
  • LayoutBuilder answers “how much room does this widget have right now?” — which might be a card inside a row inside a page, far smaller than the screen.

The rule of thumb: use MediaQuery for global concerns (window-level breakpoints, dark mode, text scaling), and LayoutBuilder when a component needs to adapt to the box its parent gives it. A dashboard card has no business reading the full screen width when it lives in a three-column grid.

The breakpoints pattern

Centralize your breakpoints so the whole app agrees on what “tablet” means:

import 'package:flutter/material.dart';

enum DeviceType { mobile, tablet, desktop }

class Breakpoints {
  static const double tablet = 600;
  static const double desktop = 900;

  static DeviceType of(double width) {
    if (width >= desktop) return DeviceType.desktop;
    if (width >= tablet) return DeviceType.tablet;
    return DeviceType.mobile;
  }
}

Then consume it through a single LayoutBuilder at the top of your screen and pass the result down — this keeps one source of truth instead of every widget re-deriving the layout:

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Dashboard')),
      body: LayoutBuilder(
        builder: (context, constraints) {
          switch (Breakpoints.of(constraints.maxWidth)) {
            case DeviceType.desktop:
              return const Row(
                children: [
                  SideNavigationBar(),
                  Expanded(child: DashboardGrid(crossAxisCount: 3)),
                ],
              );
            case DeviceType.tablet:
              return const DashboardGrid(crossAxisCount: 2);
            case DeviceType.mobile:
              return const DashboardGrid(crossAxisCount: 1);
          }
        },
      ),
    );
  }
}

The same technique drives component-level decisions, like font sizes or padding that should breathe on larger screens:

Padding(
  padding: const EdgeInsets.all(16),
  child: LayoutBuilder(
    builder: (context, constraints) {
      final titleStyle = constraints.maxWidth >= Breakpoints.tablet
          ? Theme.of(context).textTheme.headlineMedium
          : Theme.of(context).textTheme.titleLarge;
      return Text('Revenue', style: titleStyle);
    },
  ),
)

And for grids, compute the column count from the actual width instead of hard-coding it:

LayoutBuilder(
  builder: (context, constraints) {
    final columns = (constraints.maxWidth / 280).floor().clamp(1, 4);
    return GridView.builder(
      gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: columns,
        mainAxisSpacing: 12,
        crossAxisSpacing: 12,
        childAspectRatio: 1.4,
      ),
      itemCount: cards.length,
      itemBuilder: (context, i) => StatCard(cards[i]),
    );
  },
)

The nested layout gotchas

This is where most LayoutBuilder bugs come from.

Unbounded constraints inside Row/Column/Flex

Inside a Row or Column, children are laid out along the main axis with unbounded constraints — constraints.maxWidth is double.infinity if the flex child isn’t wrapped in Expanded or Flexible. A LayoutBuilder sitting directly in a Row will happily give you an infinite width to branch on, and any widget sized from it will throw a layout error.

The fix: constrain the child first, then branch:

Row(
  children: [
    Expanded(
      child: LayoutBuilder(
        builder: (context, constraints) {
          // constraints.maxWidth is finite here.
          final compact = constraints.maxWidth < Breakpoints.tablet;
          return compact ? const CompactPanel() : const WidePanel();
        },
      ),
    ),
  ],
)

The same applies inside horizontal scrollables and IntrinsicWidth-style widgets: check constraints.hasBoundedWidth (or constraints.isBounded) before using maxWidth in a comparison. If the child should be smaller than the parent, wrap it in Align; if it might want to be bigger, wrap it in SingleChildScrollView or OverflowBox — the LayoutBuilder docs call this out explicitly.

Don’t nest LayoutBuilders deeper than necessary

Every LayoutBuilder adds a render object that participates in layout, and its builder runs during the layout phase. Nesting a LayoutBuilder inside another one’s child that also re-measures (like IntrinsicWidth) can turn a cheap layout into an expensive one. Measure once per decision level and pass values down as plain parameters.

Constraints are not screen size

Inside a ListView item, a card’s LayoutBuilder reports the card’s width, not the window’s. That’s usually what you want — but if your design truly depends on the window size, read MediaQuery.sizeOf(context) instead of reaching for an ancestor LayoutBuilder.

Putting it together

A robust responsive screen usually has this shape:

  1. One top-level LayoutBuilder (or a MediaQuery check) that resolves a Breakpoints enum for the screen.
  2. Data passed down as constructor parameters (crossAxisCount, showSidebar) rather than repeated constraint checks.
  3. Component-level LayoutBuilders only where a widget genuinely adapts to its immediate box — always behind an Expanded/Flexible if it lives in a Flex child.
  4. SliverLayoutBuilder when you need the same thing inside a sliver-based scroll view (CustomScrollView).

Practical takeaways

  • LayoutBuilder runs its builder at layout time with the parent’s BoxConstraints; the widget’s size is its child’s size.
  • Use MediaQuery for window-level facts, LayoutBuilder for widget-level space; don’t mix the two roles.
  • Define breakpoints once (600/900 are common tablet/desktop thresholds) and branch on a small enum.
  • Inside Row/Column, wrap LayoutBuilder in Expanded — flex children get unbounded main-axis constraints, and maxWidth == double.infinity breaks width-based branching.
  • Avoid nesting LayoutBuilder too deeply; each one participates in the layout phase.

Conclusion & Next Steps

You now have the constraints-driven pattern Flutter’s own adaptive docs recommend: measure the real available space, resolve it through shared breakpoints, and adapt both the page structure and individual components. Next, look at SliverLayoutBuilder for scroll-driven adaptations, and the safe_area/MediaQuery guidance in Flutter’s adaptive docs for handling notches and system chrome.

Sources