Sliver widgets in Flutter explained
By Ann Tech · 24 July 2025
Sliver widgets are the foundation of Flutter's scroll performance. Every ListView, GridView, and CustomScrollView is built on top of them. Understanding slivers lets you build scroll experiences that are impossible with regular widgets — collapsing headers, mixed content, pinned sections — without fighting the framework.
What is a sliver?
A sliver is a widget that participates in a CustomScrollView's scroll model. Unlike a regular widget that has a fixed size, a sliver communicates with the scroll controller through a SliverConstraints → SliverGeometry protocol:
- SliverConstraints tell the sliver: here's how much space is available, here's the scroll offset, here's the overlap from above.
- SliverGeometry is the sliver's response: here's how big I am, here's how much I painted, here's whether I want to be pinned.
This protocol enables the scroll position to drive layout — things regular Column + SingleChildScrollView can't do.
The basics: CustomScrollView
CustomScrollView(
slivers: [
const SliverAppBar(
title: Text('Orders'),
floating: true,
snap: true,
),
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverList.builder(
itemCount: orders.length,
itemBuilder: (context, index) => OrderCard(order: orders[index]),
),
),
],
)
Everything inside slivers: must be a sliver. You can't put a Container directly — wrap it in SliverToBoxAdapter.
SliverAppBar: collapsing headers
SliverAppBar(
expandedHeight: 200,
floating: false, // Doesn't return until scrolled to top
pinned: true, // Title stays visible when collapsed
snap: false,
flexibleSpace: FlexibleSpaceBar(
title: const Text('My Orders'),
background: Image.network(
'https://example.com/header.jpg',
fit: BoxFit.cover,
),
collapseMode: CollapseMode.parallax,
),
actions: [
IconButton(icon: const Icon(Icons.search), onPressed: () {}),
],
)
pinned: true + expandedHeight gives the classic collapsing header effect where the image shrinks to a regular app bar as the user scrolls.
SliverPersistentHeader: custom pinned sections
class _SectionHeaderDelegate extends SliverPersistentHeaderDelegate {
const _SectionHeaderDelegate(this.title);
final String title;
@override
double get minExtent => 40;
@override
double get maxExtent => 40;
@override
Widget build(
BuildContext context,
double shrinkOffset,
bool overlapsContent,
) {
return Container(
color: Colors.white,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
title,
style: const TextStyle(fontWeight: FontWeight.bold),
),
);
}
@override
bool shouldRebuild(_SectionHeaderDelegate old) => old.title != title;
}
// In CustomScrollView:
SliverPersistentHeader(
pinned: true,
delegate: _SectionHeaderDelegate('Pending Orders'),
)
Mixed content scrolling
CustomScrollView(
slivers: [
// Collapsing banner
SliverAppBar(
expandedHeight: 180,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
background: Image.asset('assets/banner.jpg', fit: BoxFit.cover),
),
),
// Pinned filter bar
SliverPersistentHeader(
pinned: true,
delegate: _FilterBarDelegate(),
),
// Horizontal carousel (converted to sliver)
SliverToBoxAdapter(
child: SizedBox(
height: 120,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: featuredItems.length,
itemBuilder: (_, i) => FeaturedCard(item: featuredItems[i]),
),
),
),
// Section header
SliverPersistentHeader(
pinned: true,
delegate: _SectionHeaderDelegate('All Orders'),
),
// Main list
SliverList.builder(
itemCount: orders.length,
itemBuilder: (_, i) => OrderCard(order: orders[i]),
),
// Footer padding
const SliverPadding(padding: EdgeInsets.only(bottom: 80)),
],
)
SliverGrid
SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 3 / 2,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
),
delegate: SliverChildBuilderDelegate(
(context, index) => ProductCard(product: products[index]),
childCount: products.length,
),
)
For adaptive grid columns based on available width:
SliverGrid(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 200, // Each cell is at most 200px wide
childAspectRatio: 1,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
),
delegate: SliverChildBuilderDelegate(
(context, index) => ProductCard(product: products[index]),
childCount: products.length,
),
)
Efficient lazy loading with SliverList.builder
SliverList.builder only builds items as they scroll into view:
SliverList.builder(
itemCount: orders.length + (isLoading ? 1 : 0),
itemBuilder: (context, index) {
if (index == orders.length) {
return const Center(
child: Padding(
padding: EdgeInsets.all(16),
child: CircularProgressIndicator(),
),
);
}
return OrderCard(order: orders[index]);
},
)
SliverAnimatedList
For animated insertions and removals:
final _listKey = GlobalKey<SliverAnimatedListState>();
void insertItem(int index) {
orders.insert(index, newOrder);
_listKey.currentState!.insertItem(
index,
duration: const Duration(milliseconds: 300),
);
}
void removeItem(int index) {
final removed = orders.removeAt(index);
_listKey.currentState!.removeItem(
index,
(context, animation) => SizeTransition(
sizeFactor: animation,
child: OrderCard(order: removed),
),
);
}
// In CustomScrollView:
SliverAnimatedList(
key: _listKey,
itemBuilder: (context, index, animation) => SizeTransition(
sizeFactor: animation,
child: OrderCard(order: orders[index]),
),
)
Common pitfalls
Putting a regular widget directly in slivers. You'll get a runtime error: "A RenderSliver expected a child of type RenderSliver." Wrap with SliverToBoxAdapter.
Nested scroll views without coordination. If you put a ListView inside a CustomScrollView, it has its own scroll controller and the two fight each other. Use SliverList instead.
Setting shrinkWrap: true on a ListView inside a CustomScrollView. This forces the ListView to measure all children at once, killing lazy loading. Use SliverList.builder instead.
Forgetting SliverPadding. Adding Padding directly in slivers doesn't work — use SliverPadding to wrap other slivers.
Sign in to like, dislike, or report.