← All posts

7 min read

Slivers in Flutter: A Practical Guide to CustomScrollView

A practical guide to slivers in Flutter: CustomScrollView, SliverAppBar, SliverList, SliverGrid, sticky headers, and why slivers beat nested ListViews with shrinkWrap.

  • Flutter
  • Slivers
  • Scrolling
Cover illustration for Slivers in Flutter: A Practical Guide to CustomScrollView

Whenever I open a screen in an inherited codebase and find a ListView with shrinkWrap: true inside a SingleChildScrollView inside a Column, I know two things. Someone needed a header that scrolls away with the list, and nobody told them about slivers.

Slivers have a reputation for being advanced. They’re not, really. They’re just the pieces ListView and GridView are made of, handed to you directly so you can combine them. In this post I’ll build one screen step by step: a store page with a collapsing header, a category grid, a sticky section header and a long product list, all in one scroll.

What a sliver actually is

A normal box widget gets constraints like “be at most 400 by 800” and returns a size. A sliver gets something different: how far the viewport has scrolled, how much space is left to paint, and which direction things flow. It returns how much of itself is visible and how much scroll extent it takes up.

That lets each sliver be lazy. A SliverList only builds the children that are on screen (plus a small cache area), no matter how many items it represents. A CustomScrollView is a viewport that takes a list of slivers and scrolls them as one continuous surface.

ListView is literally a CustomScrollView with a single SliverList inside. Once you know that, slivers stop being mysterious.

Step 1: the scroll view and a collapsing app bar

class StorePage extends StatelessWidget {
  const StorePage({super.key, required this.store});
  final Store store;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: CustomScrollView(
        slivers: [
          SliverAppBar(
            pinned: true,
            stretch: true,
            expandedHeight: 220,
            flexibleSpace: FlexibleSpaceBar(
              title: Text(store.name),
              background: Image.network(store.bannerUrl, fit: BoxFit.cover),
              stretchModes: const [StretchMode.zoomBackground],
            ),
          ),
          // more slivers below
        ],
      ),
    );
  }
}

Note there’s no appBar: on the Scaffold. The SliverAppBar lives inside the scroll view so it can react to scrolling.

The flags do most of the work:

  • pinned: true keeps a collapsed toolbar visible at the top once the expanded area scrolls away.
  • floating: true makes the app bar reappear as soon as the user scrolls up, instead of waiting until they reach the top. Add snap: true (which requires floating) to make it animate fully open rather than following the finger.
  • stretch: true lets the header grow on overscroll. You’ll only see this with bouncing scroll physics, which is the default on iOS. stretchModes controls what happens to the background.

FlexibleSpaceBar handles the title shrinking and the parallax on the background image. For anything fancier, you can pass your own widget to flexibleSpace and read the current size from a LayoutBuilder.

Step 2: wrap box widgets with SliverToBoxAdapter

A CustomScrollView only accepts slivers. Put a Text in the list directly and you’ll get an error about a RenderBox where a RenderSliver was expected. When you want a normal widget in the scroll, wrap it:

SliverToBoxAdapter(
  child: Padding(
    padding: const EdgeInsets.all(16),
    child: Text(store.description),
  ),
),

Use this for one-off pieces: a banner, a search field, a section intro. Don’t use it to wrap a Column of a hundred items; a box adapter builds its whole child, which throws away the laziness you came here for.

Step 3: a grid of categories

SliverPadding(
  padding: const EdgeInsets.symmetric(horizontal: 16),
  sliver: SliverGrid.builder(
    gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
      crossAxisCount: 4,
      mainAxisSpacing: 12,
      crossAxisSpacing: 12,
    ),
    itemCount: store.categories.length,
    itemBuilder: (context, i) => CategoryChip(store.categories[i]),
  ),
),

SliverPadding is the sliver version of Padding. Wrapping a sliver in a regular Padding fails at runtime, because Padding is a box widget and expects a box child.

For grids that should adapt to screen width, SliverGridDelegateWithMaxCrossAxisExtent picks the column count from a maximum tile width. It’s my default for anything that also runs on tablets, which I cover more in responsive Flutter layouts.

Step 4: a sticky section header

This is the part people usually reach for a package to do. You don’t need one. SliverPersistentHeader with pinned: true sticks to the top (below the pinned app bar) once it scrolls there:

class _SectionHeaderDelegate extends SliverPersistentHeaderDelegate {
  const _SectionHeaderDelegate(this.title);
  final String title;

  @override
  double get minExtent => 48;

  @override
  double get maxExtent => 48;

  @override
  Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
    return Material(
      color: Theme.of(context).colorScheme.surface,
      elevation: overlapsContent ? 2 : 0,
      child: Container(
        alignment: Alignment.centerLeft,
        padding: const EdgeInsets.symmetric(horizontal: 16),
        child: Text(title, style: Theme.of(context).textTheme.titleMedium),
      ),
    );
  }

  @override
  bool shouldRebuild(_SectionHeaderDelegate oldDelegate) =>
      oldDelegate.title != title;
}

// In the slivers list:
const SliverPersistentHeader(
  pinned: true,
  delegate: _SectionHeaderDelegate('All products'),
),

The delegate defines a min and max height. When they differ, the header shrinks as you scroll, and shrinkOffset tells you how far along it is, which is handy for fading or scaling content. Make sure what you return actually fits within the current extent, or you’ll get layout errors.

Recent Flutter versions also ship PinnedHeaderSliver, which pins any widget without writing a delegate. If you’re on a current stable release it’s the shorter option for simple fixed-height headers. The delegate approach still works everywhere and gives you shrinkOffset.

Step 5: the long list

SliverList.builder(
  itemCount: products.length,
  itemBuilder: (context, i) => ProductTile(product: products[i]),
),

That’s it. It builds lazily, like ListView.builder. If every item has the same height, SliverFixedExtentList (or itemExtent on ListView) saves the framework from measuring each child, which helps with very long lists and with jumping to a position.

If you want a footer that fills whatever space is left, such as an empty state or a centered “no more items” message, SliverFillRemaining does that.

Grouping sections

Say the page has several categories, each with its own sticky header and list. With plain slivers, a pinned header stays pinned forever, so the second header would stack under the first.

SliverMainAxisGroup (available since Flutter 3.13) solves this. It wraps a header and its list into one group, and pinned headers inside a group only stick while that group is on screen:

for (final section in store.sections)
  SliverMainAxisGroup(
    slivers: [
      SliverPersistentHeader(
        pinned: true,
        delegate: _SectionHeaderDelegate(section.title),
      ),
      SliverList.builder(
        itemCount: section.products.length,
        itemBuilder: (context, i) => ProductTile(product: section.products[i]),
      ),
    ],
  ),

The result is the contacts-app behavior where each letter header pushes the previous one off the top.

Why not just nest ListViews?

Here’s the version I keep finding in codebases:

SingleChildScrollView(
  child: Column(
    children: [
      const StoreHeader(),
      GridView.count(
        crossAxisCount: 4,
        shrinkWrap: true,
        physics: const NeverScrollableScrollPhysics(),
        children: categoryChips,
      ),
      ListView.builder(
        shrinkWrap: true,
        physics: const NeverScrollableScrollPhysics(),
        itemCount: products.length,
        itemBuilder: (context, i) => ProductTile(product: products[i]),
      ),
    ],
  ),
)

It works, which is why it survives code review. But shrinkWrap: true tells the inner list to size itself to its content. To know its content height, it has to lay out every child. Your ListView.builder with a thousand products now builds a thousand tiles on the first frame. The .builder is doing nothing.

For twenty items you won’t notice. For a real product catalog, you get a slow first frame, high memory use and scroll jank, which I dig into in fixing jank in Flutter apps. The sliver version builds only what’s visible, with one scroll position and one physics, and features like sticky headers come for free.

The rule I give teams: if you’re writing shrinkWrap: true on a list that can grow, you want a CustomScrollView. Small, bounded lists inside a dialog are fine.

A few practical notes

  • Pull to refresh works: wrap the CustomScrollView in a RefreshIndicator.
  • Scroll position across tabs can be kept with a PageStorageKey on the CustomScrollView, as explained in keys in Flutter.
  • Tabs under a collapsing header are the job of NestedScrollView, which coordinates an outer header with inner scrollables. It’s more complex, so reach for it only when you actually have tabs with separate lists.
  • Mixing types is the usual error. If you see a message about a RenderBox and a RenderSliver, you put a box widget where a sliver goes, or the other way around.

Takeaways

  • A CustomScrollView scrolls a list of slivers as one surface; ListView is just a special case of it.
  • SliverAppBar with pinned, floating, snap and stretch covers most collapsing header designs.
  • Wrap box widgets in SliverToBoxAdapter and padding in SliverPadding; keep big lists in SliverList.builder or SliverGrid.builder.
  • SliverPersistentHeader gives you sticky headers without a package, and SliverMainAxisGroup scopes them per section.
  • shrinkWrap: true on a growing list builds every item up front. Replace it with slivers.

Once you’ve built one screen this way, nested scroll views start to look like the workaround they always were. If you have a scrolling screen that stutters and want someone to take a look, get in touch.

Comments

Questions, corrections or your own experience — leave a comment below (GitHub sign-in).