SliverAppBar Patterns: Flexible, Pinned, and Floating Headers in Flutter
A practical guide to SliverAppBar: pinned vs floating vs snap behavior, FlexibleSpaceBar title fading and parallax, stretch over-scroll, the pinned+floating combo, NestedScrollView for tabbed layouts, and performance rules.
Published on • October 31, 2026
AI Assistant

A plain AppBar sits at the top of the screen and does nothing interesting. The screens people remember — a photo header that dissolves into a toolbar, a bar that hides on scroll-down and snaps back on scroll-up, an over-scroll that stretches a hero image — all need the header to be coordinated with the scroll position. That is what SliverAppBar is for: per the API docs, it is “a Material Design app bar that integrates with a CustomScrollView”, letting the bar “vary in height according to the scroll offset or float above the other content.”
This post is about the header itself: choosing the behavior flags, dressing the flexible space, stretching it, and combining SliverAppBar with tabs without breaking the scroll physics.
The mental model: slivers in one scroll coordinate space
SliverAppBar is the first child of a CustomScrollView. Everything below it is also a sliver, so one scroll position drives the whole screen:
CustomScrollView(
slivers: [
SliverAppBar(expandedHeight: 220, pinned: true, flexibleSpace: /* ... */),
const SliverToBoxAdapter(child: SectionHeader('Featured')),
SliverList.builder(itemBuilder: (_, i) => Tile(i), itemCount: 200),
SliverGrid.count(crossAxisCount: 2, children: /* ... */),
],
)
The vocabulary is small: SliverToBoxAdapter bridges an ordinary box widget into sliver land, SliverList and SliverGrid lay out children lazily against the scroll offset, and SliverAppBar is the header sliver. Because they share one viewport, a collapsing header needs no ScrollController math, no MediaQuery hacks, and no AnimationController wired to a listener — the scroll pipeline computes the collapse for free. (If slivers are new to you, the slivers guide is the primer; here we stay on the app bar.)
Pinned, floating, snap: the truth table
Three booleans, frequently guessed at. The API docs ship an official sample — flutter create --sample=material.SliverAppBar.3 — that animates all six meaningful combinations; the behaviors are:
pinned | floating | snap | Behavior |
|---|---|---|---|
| false | false | false | Bar scrolls away with the content and only returns when you scroll back to the very top. |
| true | false | false | Collapsing toolbar. Bar shrinks to collapsedHeight and stays. The default “profile screen” pattern. |
| false | true | false | Bar reappears the instant you scroll up, anywhere, tracking your finger. |
| false | true | true | Bar reappears and animates fully open in one snap instead of tracking the finger. |
| true | true | false | Bar is always present, and scrolling up expands it back to expandedHeight immediately. |
| true | true | true | Always present, expands on scroll-up, then snaps fully open. |
Two constraints worth memorizing:
snapcan only betrueiffloatingis alsotrue. Setting it alone does nothing (the docs call this out explicitly).pinned: false+floating: falsemeans the bar can leave the screen entirely. If a screen has actions the user must always reach (overflow menu, cart), it needspinned: true— orfloating: truewith the understanding that it hides on scroll-down.
expandedHeight sets the fully expanded size; collapsedHeight sets the floor (it must be at least the toolbar height, plus a bottom widget if present). The bottom slot — typically a TabBar — is stacked under the toolbar and counts toward the collapsed height.
flexibleSpace and FlexibleSpaceBar
flexibleSpace is the region behind the toolbar that collapses. Almost always you put a FlexibleSpaceBar there, which “expands, collapses, and stretches” and handles the title choreography for you:
SliverAppBar(
expandedHeight: 240,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
title: const Text('Trailhead'),
background: Image.asset('assets/header.jpg', fit: BoxFit.cover),
collapseMode: CollapseMode.parallax,
expandedTitleScale: 1.6,
titlePadding: const EdgeInsets.only(left: 16, bottom: 16),
),
)
The pieces:
- Title fade/scale. The title is rendered large while expanded (
expandedTitleScale, default1.5) and scales down to normal as the bar collapses, keeping its bottom-left (or bottom-center) anchor.titlePaddingdecides where it sits when expanded; when collapsed it aligns to the standard toolbar position. The “fade” effect people describe is this scale transition plus the background sliding away. collapseMode.CollapseMode.parallax(the default) moves the background slower than the content, so it appears to slide behind;CollapseMode.pinpins the background to the top of the flexible space;CollapseMode.nonecollapses everything at the same rate. Parallax on a full-bleed photo is the standard hero-header look;pinis right for a solid color or a chart that must not shear.stretchModes. Withstretch: trueon theSliverAppBar, overscroll expands the flexible space andstretchModesdecides what happens in that space:StretchMode.zoomBackground(default),StretchMode.blurBackground, andStretchMode.fadeTitle.
Stretch and the stretch trigger
SliverAppBar(stretch: true) requires scroll physics that allow overscroll (the Material physics do on Android; iOS uses bounce physics). The default stretchTriggerOffset is 100.0 — pull down past that and onStretchTrigger fires, which is a natural place to refresh:
SliverAppBar(
stretch: true,
stretchTriggerOffset: 120,
onStretchTrigger: _refreshFeed, // AsyncCallback — may be async
expandedHeight: 260,
flexibleSpace: const FlexibleSpaceBar(
title: Text('Feed'),
stretchModes: [StretchMode.zoomBackground, StretchMode.fadeTitle],
),
)
Keep the callback idempotent: overscroll can trigger it repeatedly in quick succession, and a refresh that fires three times looks like a bug even when it is not.
The pinned + floating combination
The combo worth knowing is pinned: true, floating: true: the bar never leaves the screen, and a scroll-up gesture instantly re-expands it from collapsed to expandedHeight even in the middle of a long list. That is the behavior of most “feed with a large header” apps — collapsed at rest while reading, expanded the moment you reverse direction, so the header is always reachable without scrolling to the top. Add snap: true if you want the expansion to complete as an animation rather than follow the finger.
One detail when pinned: true with a bottom (a TabBar): the pinned portion can visually overlap content scrolling underneath. Give the bar a background color and use forceElevated: true if you want the elevation shadow whenever content is scrolled under it.
NestedScrollView for tabs inside slivers
“Collapsing header + TabBar + one list per tab” is where naive implementations break: two independent scrollables fight, and the header collapses twice or the tab body scrolls under the bar. The fix is NestedScrollView, which coordinates an outer scrollable (your SliverAppBar) with inner body scrollables so the header collapses first, then the selected tab scrolls — one overall position.
NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) => [
SliverAppBar(
expandedHeight: 200,
pinned: true,
floating: true,
forceElevated: innerBoxIsScrolled,
bottom: TabBar(tabs: _tabs),
flexibleSpace: const FlexibleSpaceBar(title: Text('Explore')),
),
],
body: TabBarView(children: _tabBodies),
)
The rule that keeps this working: the inner lists must use the controllers NestedScrollView provides (via CustomScrollView(controller: PrimaryScrollController.of(context), ...)) and must not own a private ScrollController. And shrinkWrap: true on an inner ListView is a smell — you already have a viewport, so make the body a sliver instead of nesting a second scrollable.
Performance notes
consteverything that can beconst. The header sliver rebuilds its subtree on scroll-driven extent changes; a constFlexibleSpaceBarsubtree skips that work.- Use keys in list children (
ValueKey(item.id)) so collapsing the header does not reshuffle element state in the slivers below. - Builder constructors only.
SliverList.builderandSliverChildBuilderDelegatebuild visible children on demand; the non-builder forms build every child eagerly. - Keep the
backgroundcheap. A full-bleedImage.assetis fine; a blur or shader applied per frame during collapse is not. Do heavy work once in the widget, not in a builder that runs every scroll tick. - Avoid external
AnimationControllers for collapse. If you find yourself listening to aScrollControllerto animate a header, you have reinventedSliverAppBarbadly.
A complete screen
class TrailheadScreen extends StatelessWidget {
const TrailheadScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 260,
pinned: true,
floating: true,
stretch: true,
stretchTriggerOffset: 120,
onStretchTrigger: () async {/* refresh */},
flexibleSpace: const FlexibleSpaceBar(
title: Text('Trailhead'),
background: Image.asset('assets/peak.jpg', fit: BoxFit.cover),
collapseMode: CollapseMode.parallax,
stretchModes: [StretchMode.zoomBackground, StretchMode.fadeTitle],
),
),
SliverList.builder(
itemCount: 120,
itemBuilder: (context, i) => ListTile(
key: ValueKey('trail-$i'),
title: Text('Route ${i + 1}'),
subtitle: const Text('6.2 km · 410 m gain'),
),
),
],
),
);
}
}
One scroll position, zero offset math, and every behavior — collapse, re-expand, stretch, parallax — comes from the layout system rather than from listeners.