Skip to content
Blog

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:

pinnedfloatingsnapBehavior
falsefalsefalseBar scrolls away with the content and only returns when you scroll back to the very top.
truefalsefalseCollapsing toolbar. Bar shrinks to collapsedHeight and stays. The default “profile screen” pattern.
falsetruefalseBar reappears the instant you scroll up, anywhere, tracking your finger.
falsetruetrueBar reappears and animates fully open in one snap instead of tracking the finger.
truetruefalseBar is always present, and scrolling up expands it back to expandedHeight immediately.
truetruetrueAlways present, expands on scroll-up, then snaps fully open.

Two constraints worth memorizing:

  • snap can only be true if floating is also true. Setting it alone does nothing (the docs call this out explicitly).
  • pinned: false + floating: false means the bar can leave the screen entirely. If a screen has actions the user must always reach (overflow menu, cart), it needs pinned: true — or floating: true with 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, default 1.5) and scales down to normal as the bar collapses, keeping its bottom-left (or bottom-center) anchor. titlePadding decides 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.pin pins the background to the top of the flexible space; CollapseMode.none collapses everything at the same rate. Parallax on a full-bleed photo is the standard hero-header look; pin is right for a solid color or a chart that must not shear.
  • stretchModes. With stretch: true on the SliverAppBar, overscroll expands the flexible space and stretchModes decides what happens in that space: StretchMode.zoomBackground (default), StretchMode.blurBackground, and StretchMode.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

  • const everything that can be const. The header sliver rebuilds its subtree on scroll-driven extent changes; a const FlexibleSpaceBar subtree 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.builder and SliverChildBuilderDelegate build visible children on demand; the non-builder forms build every child eagerly.
  • Keep the background cheap. A full-bleed Image.asset is 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 a ScrollController to animate a header, you have reinvented SliverAppBar badly.

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.

Further reading