Blog
12

SliverLayoutBuilder: The Escape Hatch Inside the Other Escape Hatch

SliverLayoutBuilder in Flutter: Responsive Layouts Inside a CustomScrollView

April 11, 2026

Why LayoutBuilder quietly loses its mind inside a CustomScrollView, and what to reach for instead

You read our post o LayoutBuilder. You learned about LayoutBuilder. It felt really clever. You had a grid that needed to change its column count based on the width of its slot, so you wrapped it in a LayoutBuilder, and the callback happily reported constraints.maxWidth: 744 because you'd put the grid inside a sidebar-aware layout. Everything worked. You left the office feeling like a god of Flutter. At least, that's what I did first time when I understood this.

Then a week later you tried the same trick inside a CustomScrollView. You had a sliver-based page — a sliver app bar that collapses on scroll, a sliver grid below it, maybe a sliver list under that. You wanted to switch the grid's column count based on the available cross-axis width. You reached for your new friend. You wrapped a chunk of content in a LayoutBuilder. And you got one of these:

javascript
A RenderLayoutBuilder expected a child of type RenderBox but received a child of type RenderSliver.

Or, if you managed to sidestep that one, you got a different flavor: the constraints your LayoutBuilder received didn't match the world it was living in. maxHeight was infinity. maxWidth was correct-ish but the widget still didn't feel right. The thing you built outside a scroll view, which had behaved so well, was suddenly flailing.

What happened? And why does Flutter have a separate widget called SliverLayoutBuilder that you've probably never used?

This post is about that.

A quick detour: what a sliver actually is

You cannot talk about SliverLayoutBuilder without first saying, clearly, what the word sliver even means in Flutter, because most people pick it up vaguely ("it's a scrolling thing") and then get confused forever.

A sliver is a piece of a scrollable area. Not a scrollable widget, not a list, not a grid — a piece of one. A CustomScrollView is a container that holds a series of slivers stacked along the scroll axis, and each sliver knows how to render some fragment of the scrollable content: a collapsing header, a fixed-height row of tabs, a grid of 500 items, a list of 50 more items, a footer. Each of those fragments is a sliver.

The reason slivers exist as a separate concept is that scrolling is not just "a big tall column inside a clipping box." Scrolling is a delicate negotiation between a viewport (the visible window) and the content (which may be much larger than the window). The viewport asks each sliver questions like "how much of you is currently visible?", "what's your scroll offset?", "how much cross-axis space do you have?", and "if I scroll you by another 200 pixels, what should I paint?" Those questions are fundamentally different from the normal "what size do you want to be?" question that RenderBox widgets answer. A regular box layout has two dimensions: width and height. A sliver layout has something more like a protocol — an ongoing conversation between the viewport and each sliver about what to paint, where, how much has scrolled past, and how much remains.

Flutter implements this by having two entirely separate render object families:

  • `RenderBox` — the normal kind. Answers "how big are you given these BoxConstraints?" and returns a Size. This is what almost every widget you've ever used produces under the hood: Text, Container, Row, Column, Card, Padding, everything.
  • `RenderSliver` — the scrollable kind. Answers "given these SliverConstraints (which include scroll offset, remaining paint extent, cross-axis extent, etc.), what's your SliverGeometry?" This is what SliverList, SliverGrid, SliverAppBar, SliverToBoxAdapter, and friends produce.

These two families do not speak the same language. A RenderBox cannot directly parent a RenderSliver, and a RenderSliver cannot directly parent a RenderBox. There are specific bridge widgets for converting between them — SliverToBoxAdapter wraps a box widget to make it usable as a sliver, and SliverFillRemaining does something similar for "fill the rest of the viewport." Without those bridges, trying to mix them throws the kind of expected RenderBox, got RenderSliver errors you saw above.

And here is the thing that matters for this post: LayoutBuilder is a RenderBox widget. It speaks box. It expects to hand down BoxConstraints, and it expects to receive a box size back. So when you stick it inside a CustomScrollView, where everything else is a sliver, it either refuses to work or speaks the wrong dialect.

That's the gap SliverLayoutBuilder fills.

What SliverLayoutBuilder actually does

SliverLayoutBuilder is the sliver-protocol cousin of LayoutBuilder. Same basic shape — a widget that takes a callback and postpones building until layout time — but with sliver inputs and sliver outputs.

Where LayoutBuilder's callback is `(BuildContext, BoxConstraints) =

  • Widget, SliverLayoutBuilder's callback is (BuildContext, SliverConstraints) =
  • Widget`. Both postpone the decision of "what widget tree to return" until the layout phase, so that the callback has real, live constraints in hand. The difference is the type of constraint object, and the type of widget you're expected to return.

SliverConstraints contains a richer set of information than BoxConstraints, because sliver layout is a richer conversation:

  • crossAxisExtent — how much cross-axis space the sliver has (the width of a vertical scroll, or the height of a horizontal one). This is the one you'll probably use most.
  • scrollOffset — how far the user has scrolled past the top of this particular sliver.
  • remainingPaintExtent — how much of the viewport is still available below this point, which matters if you're deciding how tall to draw something that fills the rest of the screen.
  • viewportMainAxisExtent — the size of the entire visible viewport along the scroll axis.
  • axisDirection, growthDirection, userScrollDirection — which way the sliver is laid out, which way it's growing, which way the user is actively scrolling right now.
  • overlap — how much of this sliver is hidden behind a previous pinned sliver (like a pinned app bar).

You don't usually need all of these. Most uses of SliverLayoutBuilder are going to grab crossAxisExtent, decide on a layout variant, and return a sliver. But the richer input is there for when you need it — for example, progressively revealing content based on scroll position, or swapping from a grid to a list when cross-axis width drops below a threshold while staying inside a single CustomScrollView.

The concrete case: a responsive grid inside a CustomScrollView

Here's a scenario. You're building a product catalog page. It has:

  • A collapsing hero banner at the top (a SliverAppBar with expandedHeight: 240)
  • A sticky row of category chips below the banner
  • A grid of product cards that takes up most of the page
  • A "load more" footer at the bottom

Everything is inside a CustomScrollView because you want the banner to collapse on scroll and the chips to stick. You want the grid to show two columns on narrow screens, three on medium, and four on wide screens. And you want that decision to be based on the actual cross-axis width the grid has, not the device width — because later you might add a left sidebar and then the cross-axis width will be smaller than the screen.

Your instinct is LayoutBuilder. You try to wrap the SliverGrid in a LayoutBuilder and get the "expected RenderBox but received RenderSliver" error. You try to wrap the CustomScrollView itself in a LayoutBuilder, which technically works but now you're reading the entire screen width, not the slot — the very problem LayoutBuilder was supposed to solve. You're back where you started.

SliverLayoutBuilder is designed exactly for this:

dart
CustomScrollView(
  slivers: [
    const SliverAppBar(
      expandedHeight: 240,
      flexibleSpace: FlexibleSpaceBar(title: Text('Products')),
    ),
    const SliverPersistentHeader(
      pinned: true,
      delegate: CategoryChipsDelegate(),
    ),
    SliverLayoutBuilder(
      builder: (context, constraints) {
        final columns = switch (constraints.crossAxisExtent) {
          < 600 => 2,
          < 900 => 3,
          _ => 4,
        };
        return SliverGrid(
          gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
            crossAxisCount: columns,
            mainAxisSpacing: 12,
            crossAxisSpacing: 12,
            childAspectRatio: 0.72,
          ),
          delegate: SliverChildBuilderDelegate(
            (context, index) => ProductCard(product: products[index]),
            childCount: products.length,
          ),
        );
      },
    ),
    const SliverToBoxAdapter(
      child: LoadMoreFooter(),
    ),
  ],
)

The SliverLayoutBuilder sits in the sliver list alongside its sliver siblings. Its callback receives the real cross-axis extent for its position in the viewport, decides on a column count, and returns another sliver (SliverGrid) as its output. The return type matters: the callback must return a widget whose root is a sliver, not a box. Returning a Container or a Row from this callback will throw the same RenderBox/RenderSliver mismatch error you were trying to avoid.

Notice what this gives you that plain LayoutBuilder couldn't. If you later drop this entire CustomScrollView into a two-pane tablet layout with a 280-pixel sidebar on the left, the SliverLayoutBuilder's crossAxisExtent automatically reflects the narrower slot. No MediaQuery math, no awareness of the sidebar, no prop-drilling of a column count from above. The sliver asks the sliver protocol "how wide am I right now?" and the sliver protocol tells it the truth.

When it reruns, and the cost of that

SliverLayoutBuilder has the same rebuild characteristic as LayoutBuilder: when its incoming constraints change, its callback reruns and the subtree rebuilds. In the sliver world, "incoming constraints change" includes some things you might not expect:

  • The user rotates the device (cross-axis extent changes — expected).
  • The window resizes on desktop or web (cross-axis extent changes — expected).
  • A parent layout animates open or closed, changing the viewport dimensions (cross-axis extent changes — expected).
  • The user scrolls (scroll offset changes, and depending on how SliverConstraints equality is computed for the specific sliver, the callback may or may not rerun).

That last point is the one to watch. In practice, SliverLayoutBuilder is smart about reusing its child when the constraints that actually matter haven't changed — scrolling alone won't necessarily rebuild it — but if your callback is reading scrollOffset and making decisions based on it, you'll be triggering rebuilds on every frame of a scroll. That is almost never what you want. Scroll-position-based decisions should usually be made with a NotificationListener<ScrollNotification> or a ScrollController-driven AnimatedBuilder, not a SliverLayoutBuilder's rebuild cycle.

The healthy use of SliverLayoutBuilder is: branch on cross-axis space, not scroll state. Reach for the other tools when the thing you're reacting to is the user's scroll position.

The "but can't I just wrap the whole thing?" question

It's tempting to think: forget all this sliver-specific stuff, I'll just put a LayoutBuilder around the CustomScrollView itself and read the width from there. Technically, this works. You'll get the cross-axis width of the whole scroll view, which in most layouts is the same number the SliverLayoutBuilder would have read. So what's the difference?

Two things.

First, composition. If the LayoutBuilder is outside the CustomScrollView, the decision about column count is being made at the boundary of the scroll area, and whatever you return from LayoutBuilder is a whole new CustomScrollView each time the constraints change. You're rebuilding the entire scroll view every time. If your CustomScrollView has a bunch of slivers in it, that's a lot of rebuilding for what should be a localized decision. SliverLayoutBuilder rebuilds only its own subtree — specifically, only the single sliver whose structure depends on the constraint. The other slivers in the scroll view are untouched.

Second, correctness for nested cases. If the layout changes so that a single sliver has a different cross-axis width than its siblings — for example, via a sliver that adds cross-axis padding, or a sliver viewport inside another viewport — then the outer LayoutBuilder's number is wrong for that specific sliver. The SliverLayoutBuilder's number is always correct for its position in the sliver pipeline, because it's reading the constraints its own parent just handed it, not the constraints the outermost box received.

In the common case these two approaches produce the same number, so "just use LayoutBuilder outside" mostly works for simple pages. The moment your sliver tree gets even slightly nontrivial, SliverLayoutBuilder is the correct tool and plain LayoutBuilder starts lying to you.

The mental hierarchy you should walk away with

Here's the decision tree, cleanly:

  • The information you need is the whole window or device dimensions (keyboard height, safe area insets, text scale factor, orientation) → MediaQuery.
  • The information you need is the local box slot's width or height, and you're in normal box-layout landLayoutBuilder.
  • The information you need is the local sliver slot's cross-axis extent or other sliver-protocol values, and you're inside a CustomScrollViewSliverLayoutBuilder.
  • The information you need is how much space children of a Row or Column end up using, so siblings can agree on a dimensionIntrinsicHeight / IntrinsicWidth.
  • The information you need is the user's current scroll position, to trigger effects or animationsScrollController + AnimatedBuilder, or a NotificationListener<ScrollNotification>. Not a layout builder of any kind.

These tools are not interchangeable, and they're not ranked by "goodness." They're ranked by the specific question they can answer. Most layout pain in Flutter comes from reaching for the wrong one.

The cheat sheet line for SliverLayoutBuilder

SliverLayoutBuilder is LayoutBuilder for the sliver protocol. Use it when you're inside a CustomScrollView and you need to branch on the actual cross-axis space your sliver has been given — not the whole screen, not the whole viewport, just your sliver's slot. It must return a widget whose root is a sliver, its callback reruns when the sliver constraints that matter change, and it should branch on space, not on scroll position. That's basically it.

LayoutBuilder and SliverLayoutBuilder exist as a pair for the same reason RenderBox and RenderSliver exist as a pair: Flutter has two layout protocols, and each one needs its own "postpone the decision until constraints are known" widget. If you're in box land, use the box one. If you're in sliver land, use the sliver one. Mixing them throws. That's the whole story.

Related Topics

flutter sliverlayoutbuilderflutter customscrollviewflutter sliversrendersliver vs renderboxflutter responsive grid sliverflutter sliverconstraintsflutter layoutbuilder sliverflutter sliver protocol

Ready to build your app?

Flutter apps built on Clean Architecture — documented, tested, and yours to own. See which plan fits your project.