devShakib

Slivers Demystified: Collapsing Headers, Parallax, and When to Write a RenderSliver

Master Flutter slivers: how SliverConstraints and SliverGeometry drive collapsing headers, parallax with shrinkOffset, CustomScrollView, and custom RenderSliver.

Every Flutter developer hits slivers the moment they want a scroll effect that ListView can't give them: a header that shrinks as you scroll, an image that drifts at half speed, a toolbar that snaps. Most of us reach for SliverAppBar, get 80% there, and then fight the framework for the last 20% because we never actually learned what a sliver is. After shipping a few production apps with heavy custom scroll UIs, I've found that understanding the sliver protocol — not memorizing widget names — is what turns that fight into a five-minute change.

This is a mental-model post as much as a how-to. Once the protocol clicks, CustomScrollView, SliverAppBar, SliverPersistentHeader, and even a hand-written RenderSliver stop being separate topics and become one idea viewed from different distances.

What is a sliver in Flutter?

A sliver is a scrollable region that speaks a lazy layout protocol. Where a normal box widget gets BoxConstraints (min/max width and height) and returns a Size, a sliver gets SliverConstraints and returns SliverGeometry. That difference is the whole game, and it's why you can't just drop a Container into a CustomScrollView — the box protocol and the sliver protocol don't speak the same language, which is exactly why SliverToBoxAdapter exists as a translator.

The word "lazy" is doing real work here. A SliverList never builds the rows you can't see. It builds a window of children around the viewport, plus a small cache buffer, and disposes the rest. That's what lets you scroll a list of 100,000 items at 60fps without allocating 100,000 widgets. The laziness is baked into the protocol, not bolted on — you get it for free the moment you're in sliver land.

SliverConstraints: what the sliver knows

SliverConstraints tells a sliver everything about the scroll state around it:

SliverGeometry: what the sliver reports back

In return, the sliver produces SliverGeometry, whose important fields are:

The subtlety that trips people up: scrollExtent, paintExtent, and layoutExtent are independent. A pinned header claims a small scrollExtent but keeps a constant paintExtent. A floating header can paint while claiming zero layout. Once you see these three as separate dials, collapsing headers stop being magic — a SliverAppBar is just a machine that sets these three numbers differently depending on pinned, floating, and snap.

CustomScrollView is just a sliver host

CustomScrollView does almost nothing itself. It owns a Viewport, walks its slivers list, hands each one a SliverConstraints derived from the current scroll position, and stacks the results using each sliver's layoutExtent. ListView and GridView are thin wrappers that inject a single SliverList/SliverGrid for you. NestedScrollView is a more elaborate host that runs two coordinated sliver worlds so an outer header and an inner TabBarView can share one gesture.

The practical consequence: you can mix anything.

CustomScrollView(  slivers: [    SliverPersistentHeader(pinned: true, delegate: CollapsingHeader(title: 'Profile')),    const SliverToBoxAdapter(child: ProfileStats()),    SliverList.builder(      itemCount: posts.length,      itemBuilder: (context, i) => PostTile(posts[i]),    ),    const SliverPadding(padding: EdgeInsets.only(bottom: 80)),  ],)

A SliverToBoxAdapter wraps a normal box widget, a SliverList lazily builds rows, a SliverPersistentHeader gives you the pinned/floating behavior, and they all compose in one scroll view sharing one ScrollController. This is why you should stop nesting scrollables and start thinking in slivers the moment you have more than one scrolling section. Nested ListViews each fight for the gesture, each maintain their own physics, and the scrollbar becomes a lie. One CustomScrollView gives you a single unified scroll with correct physics and one honest scrollbar.

SliverPersistentHeader: the collapsing-header workhorse

SliverPersistentHeader is where most custom scroll effects actually live. You give it a SliverPersistentHeaderDelegate with a minExtent, a maxExtent, and a build that receives shrinkOffset — how many pixels the header has collapsed, from 0 up to maxExtent - minExtent.

That shrinkOffset is your animation driver. Interpolate anything off it: title size, opacity, image scale, blur radius, background color. Here's a collapsing header that fades and shrinks a title, with the delegate correctly reporting when it needs to rebuild:

class CollapsingHeader extends SliverPersistentHeaderDelegate {  const CollapsingHeader({required this.title, this.expanded = 260});  final String title;  final double expanded;  @override  double get maxExtent => expanded;  @override  double get minExtent => kToolbarHeight;  @override  Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {    final range = (maxExtent - minExtent);    // 0.0 fully expanded -> 1.0 fully collapsed    final t = (shrinkOffset / range).clamp(0.0, 1.0);    return Material(      color: Color.lerp(Colors.transparent, Colors.black, t),      child: Stack(        fit: StackFit.expand,        children: [          Opacity(            opacity: 1 - t,            child: Image.asset('assets/cover.jpg', fit: BoxFit.cover),          ),          Align(            alignment: Alignment.lerp(              Alignment.bottomLeft, Alignment.centerLeft, t),            child: Padding(              padding: const EdgeInsets.symmetric(horizontal: 16),              child: Text(                title,                style: TextStyle(                  color: Colors.white,                  fontSize: lerpDouble(28, 18, t),                  fontWeight: FontWeight.w700,                ),              ),            ),          ),        ],      ),    );  }  @override  bool shouldRebuild(covariant CollapsingHeader old) =>      old.title != title || old.expanded != expanded;}

Drop that into SliverPersistentHeader(pinned: true, delegate: CollapsingHeader(title: 'Profile')) and you have the SliverAppBar behavior with none of its opinions. Set pinned: false, floating: true instead and the same delegate becomes a header that scrolls away and snaps back the moment you drag down — the framework drives shrinkOffset differently, your build code doesn't change.

The gotchas that cost people hours

Parallax in Flutter without a jank tax

The lazy way to do parallax is to attach a listener to a ScrollController and call setState on every notification. Don't. That rebuilds widgets every frame on the UI thread and fights the scroll animation — you end up a frame behind the finger, which is exactly the "cheap" feeling you were trying to avoid.

The right primitive is to drive the effect from layout, not from a listener. For a header-level parallax, shrinkOffset is already everything you need. Translate the background image by a fraction of it so the image drifts slower than the foreground:

Transform.translate(  offset: Offset(0, shrinkOffset * 0.5), // background drifts at half speed  child: Image.asset('assets/cover.jpg', fit: BoxFit.cover),)

The key insight: shrinkOffset is delivered during layout, so anything you drive from it stays perfectly in sync with the scroll physics — no listener, no extra rebuild, no frame lag. That is the difference between parallax that feels attached to the finger and parallax that lags a frame behind.

For parallax inside list items — where each item's image drifts as it scrolls through the viewport — the same principle applies, but shrinkOffset isn't available per-item. The clean answer is Flow with a FlowDelegate, or reading the item's position relative to the viewport at paint time. The standard Flutter parallax recipe uses a FlowDelegate that reads the render object's position and offsets each image accordingly. The rule stays constant: compute the drift from geometry that's already available during layout or paint, never from a setState firing on a scroll notification.

When to write a custom RenderSliver

Ninety-five percent of effects are SliverPersistentHeader plus some lerp. You should reach for a custom RenderSliver — usually by extending RenderSliverSingleBoxAdapter, or a SliverMultiBoxAdaptorWidget for multi-child cases — only when you need to control the geometry protocol itself, not just paint differently. Concrete signals that you've hit the wall:

When you do drop down, the contract is small: override performLayout, read constraints (a SliverConstraints), lay out your child against a derived BoxConstraints, and set geometry = SliverGeometry(...) with scrollExtent, paintExtent, and layoutExtent that are internally consistent.

class RenderStretchHeader extends RenderSliverSingleBoxAdapter {  @override  void performLayout() {    final SliverConstraints c = constraints;    // Grow past maxExtent when overscrolling at the top.    final double stretch = c.overlap < 0 ? -c.overlap : 0.0;    final double extent = _baseExtent + stretch;    child?.layout(c.asBoxConstraints(maxExtent: extent), parentUsesSize: true);    final double paint = extent.clamp(0.0, c.remainingPaintExtent);    geometry = SliverGeometry(      scrollExtent: _baseExtent,           // claim a stable amount of scroll space      paintExtent: paint,                  // but paint the stretched size      maxPaintExtent: extent,      layoutExtent: paint.clamp(0.0, c.remainingPaintExtent),    );  }}

Get those three extents wrong relative to each other and you'll see overscroll glitches, a wrong scrollbar, or siblings that overlap. Get them right and you have a scroll effect no built-in widget could have given you. The debugging move when things look off is always the same: print constraints and geometry side by side in performLayout and check that paintExtent <= remainingPaintExtent and that layoutExtent <= paintExtent.

Key takeaways

Slivers aren't a bag of special widgets — they're one protocol, seen at different distances. Internalize that constraints go in and geometry comes out, and the rest of the scrolling toolkit stops being a fight and starts being a set of dials you already know how to turn.