Almost every real Flutter screen scrolls. Get scrolling right and the app feels native; get it wrong and you hit the two most-Googled Flutter errors — RenderFlex overflow and unbounded height — within an hour. This guide takes you from the everyday ListView all the way to CustomScrollView and slivers, explaining the mental model that ties them together so you always know which scrolling widget to reach for.

The Complete Flutter Guide: Build Android, iOS and Web apps
Go from scratch to building industry-standard apps with Riverpod, Firebase, animations, REST APIs, and more.
Enrol nowEvery snippet below is paste-ready against current stable Flutter. Drop each one into a Scaffold body to see it render. If layout fundamentals are still shaky, read the Flutter Layout Widgets guide first — scrolling sits directly on top of the constraints model it explains.
We will start with the scrolling mental model — viewports and slivers — because it makes everything else click. Then we cover the convenience widgets (SingleChildScrollView, ListView, GridView), move to CustomScrollView and the sliver family, and finish with ScrollController, NestedScrollView, pull-to-refresh, performance, and the errors everyone hits.
This is one of three Flutter pillar guides published together. The companion Flutter Animations guide and the Dart Language for Flutter guide round out the UI and language foundations you will lean on throughout.
The mental model: viewports and slivers
Every scrollable in Flutter is built from two parts. A Scrollable handles the gesture and tracks the scroll offset; a Viewport is the window you see through, and it lays out its children lazily. The children inside a viewport are not ordinary box widgets — they are slivers. A sliver is a scrollable slice that negotiates its geometry with the viewport as the offset changes, so it can build only the part currently visible.
You rarely write that machinery by hand. ListView, GridView, and SingleChildScrollView are convenience widgets that wrap a Scrollable and a Viewport for you. The moment you need to combine several scrolling pieces in one gesture — a header that collapses, then a grid, then a list — you drop down to CustomScrollView and supply the slivers yourself. Hold onto one rule: a viewport gives its slivers unbounded space in the scroll direction. That single fact explains most scrolling errors later in this guide.
SingleChildScrollView: make any content scroll
SingleChildScrollView is the simplest scroller. It takes one child and lets it scroll when it would otherwise overflow. It is perfect for a form or a settings page that is occasionally taller than the screen — but because it builds its entire child immediately, it is the wrong tool for long or dynamic lists.
SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: const [
Text('A long form', style: TextStyle(fontSize: 24)),
SizedBox(height: 12),
TextField(decoration: InputDecoration(labelText: 'Name')),
SizedBox(height: 12),
TextField(decoration: InputDecoration(labelText: 'Email')),
// ...more fields than fit on screen
],
),
)
A classic trap is wrapping a Column inside a SingleChildScrollView and then adding an Expanded child. Expanded needs a bounded height to flex into, but the scroll view offers unbounded height — so it throws. Inside a scroll view, give children explicit sizes instead of asking them to expand.
ListView: the everyday scroller
ListView is what you will use most. The default constructor takes a children list and is fine for a handful of fixed items. For anything long or data-driven, use ListView.builder, which builds items lazily as they scroll into view.
// Fixed, short list
ListView(
children: const [
ListTile(leading: Icon(Icons.home), title: Text('Home')),
ListTile(leading: Icon(Icons.search), title: Text('Search')),
ListTile(leading: Icon(Icons.person), title: Text('Profile')),
],
)
// Long or dynamic list — lazy and efficient
ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return ListTile(
title: Text(item.title),
subtitle: Text(item.subtitle),
);
},
)
Use ListView.separated when you want dividers or spacing between rows without baking them into each item — it builds an extra widget between every pair of children.
ListView.separated(
itemCount: items.length,
separatorBuilder: (context, index) => const Divider(height: 1),
itemBuilder: (context, index) => ListTile(title: Text(items[index])),
)
To scroll sideways instead of down, set scrollDirection: Axis.horizontal and give each child a width. A horizontal list inside a vertical one needs a bounded height, so wrap it in a SizedBox.
SizedBox(
height: 140,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: cards.length,
itemBuilder: (context, index) => Container(
width: 120,
margin: const EdgeInsets.all(8),
color: Colors.amber.shade100,
child: Center(child: Text(cards[index])),
),
),
)
GridView: rows and columns that scroll
GridView.builder is the lazy grid. The gridDelegate decides how many columns you get. Use SliverGridDelegateWithFixedCrossAxisCount for a fixed column count, or SliverGridDelegateWithMaxCrossAxisExtent to let the grid pick the count from a maximum tile width — the responsive choice.
GridView.builder(
padding: const EdgeInsets.all(12),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 180,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 3 / 4,
),
itemCount: products.length,
itemBuilder: (context, index) => Card(
child: Center(child: Text(products[index].name)),
),
)
The delegate names give the game away: a grid is a sliver under the hood, which is exactly why you can drop a SliverGrid straight into a CustomScrollView next.

Build scroll-heavy screens the right way
The Complete Flutter Guide walks you through lists, grids, and collapsing headers in real apps, with Riverpod and REST APIs.
Enrol nowCustomScrollView and slivers
CustomScrollView is the general-purpose scroller. Instead of one kind of child, it takes a slivers list, and scrolls them together as a single surface. This is how you build a screen that has a collapsing app bar, then a grid, then a list — all sharing one scroll gesture.
CustomScrollView(
slivers: [
const SliverAppBar(
title: Text('Explore'),
floating: true,
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => ListTile(title: Text('Item $index')),
childCount: 20,
),
),
SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3),
delegate: SliverChildBuilderDelegate(
(context, index) => Card(child: Center(child: Text('$index'))),
childCount: 12,
),
),
],
)
Two shortcuts make slivers easier to read. SliverList.builder and SliverGrid.builder mirror their box-widget cousins, and SliverToBoxAdapter lets you drop a single ordinary widget — a banner, a heading — into the sliver list.
CustomScrollView(
slivers: [
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(16),
child: Text('Featured', style: TextStyle(fontSize: 22)),
),
),
SliverList.builder(
itemCount: 30,
itemBuilder: (context, index) => ListTile(title: Text('Row $index')),
),
],
)
SliverAppBar: collapsing and pinned headers
SliverAppBar is the reason most people learn slivers. Give it an expandedHeight and a FlexibleSpaceBar, and it expands to show a banner that collapses into a normal toolbar as you scroll. The behaviour flags are the part worth memorising:
pinned: true— the collapsed toolbar stays on screen at the top.floating: true— the bar reappears as soon as you scroll up, without scrolling all the way back.snap: true— withfloating, the bar animates fully open or closed rather than half-way.
CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 220,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
title: const Text('Mountains'),
background: Image.network(
'https://picsum.photos/800/400',
fit: BoxFit.cover,
),
),
),
SliverList.builder(
itemCount: 30,
itemBuilder: (context, index) => ListTile(title: Text('Trail $index')),
),
],
)
SliverFillRemaining and SliverPersistentHeader
Two more slivers cover common needs. SliverFillRemaining fills whatever viewport space is left after the other slivers — ideal for an empty state or a centred message under a short list. SliverPersistentHeader builds a header that can stick (pinned) or hide on scroll (floating), useful for sticky section labels or a search bar that clings to the top.
CustomScrollView(
slivers: [
SliverList.builder(
itemCount: 3,
itemBuilder: (context, index) => ListTile(title: Text('Item $index')),
),
const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: Text('Nothing more to load')),
),
],
)
SliverPersistentHeader needs a SliverPersistentHeaderDelegate where you implement build, maxExtent, minExtent, and shouldRebuild. It is more code than a SliverAppBar, so reach for it only when you genuinely need a custom sticky section that an app bar cannot express.

From widgets to shipped apps
The Complete Flutter Guide takes you from these building blocks to production apps on Android, iOS, and the web.
Enrol nowScrollController: react to and drive scrolling
A ScrollController attaches to any scrollable and gives you two powers: read the current offset, and drive the scroll programmatically. Create it in initState, dispose it, and pass it to the scrollable's controller.
class _FeedState extends State<Feed> {
final _controller = ScrollController();
bool _showFab = false;
@override
void initState() {
super.initState();
_controller.addListener(() {
final show = _controller.offset > 400;
if (show != _showFab) setState(() => _showFab = show);
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _toTop() => _controller.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
@override
Widget build(BuildContext context) {
return Scaffold(
body: ListView.builder(
controller: _controller,
itemCount: 100,
itemBuilder: (context, index) => ListTile(title: Text('Row $index')),
),
floatingActionButton: _showFab
? FloatingActionButton(onPressed: _toTop, child: const Icon(Icons.arrow_upward))
: null,
);
}
}
The same controller powers infinite scroll: in the listener, when offset nears position.maxScrollExtent, load the next page of data. For a full walkthrough of paginated feeds and the RefreshIndicator, the dedicated tutorials in the Flutter hub go deeper than there is room for here.
NestedScrollView: a header above tabbed lists
When you want a single collapsing header sitting above a TabBarView where each tab has its own scrollable, a plain CustomScrollView is not enough — the outer and inner scrollables would fight. NestedScrollView coordinates them: its headerSliverBuilder supplies the slivers that scroll away, and the body holds the tabbed inner scrollables.
NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) => [
const SliverAppBar(
title: Text('Profile'),
pinned: true,
expandedHeight: 180,
bottom: TabBar(tabs: [Tab(text: 'Posts'), Tab(text: 'Media')]),
),
],
body: const TabBarView(
children: [
Center(child: Text('Posts list')),
Center(child: Text('Media grid')),
],
),
)
Wrap the whole thing in a DefaultTabController so the TabBar and TabBarView stay in sync.
Pull to refresh
RefreshIndicator wraps any scrollable and shows the spinner when the user drags down from the top. Its onRefresh returns a Future; the spinner stays until that future completes, so just await your data load.
RefreshIndicator(
onRefresh: () async {
await context.read<FeedRepository>().reload();
},
child: ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) => ListTile(title: Text(items[index])),
),
)
The scrollable must always be scrollable for the gesture to register. If your list is short, set physics: const AlwaysScrollableScrollPhysics() so the pull still works.
Scroll physics and performance
The physics property controls the feel. BouncingScrollPhysics gives the iOS rubber-band, ClampingScrollPhysics the Android glow, and NeverScrollableScrollPhysics disables scrolling (handy when an outer scrollable should own the gesture). Leave it unset to match the platform automatically.
For smooth, long lists, a few habits matter:
- Always use the
.builderconstructors for long lists and grids so widgets build lazily. - Give rows a stable
Keywhen the list reorders, so Flutter recycles element state correctly. - Add
itemExtentorprototypeItemto aListViewwhen every row is the same height — Flutter then skips measuring each child and scrolls more cheaply. - Keep item builders cheap. Heavy work per row (decoding images, parsing) belongs in a cache or a future, not in
itemBuilder.
For the wider performance picture — Impeller, DevTools, and rebuild reduction — see the Flutter Performance guide.
Common scrolling errors and fixes
- "Vertical viewport was given unbounded height." You nested a scrollable inside another without bounding it — for example a
ListViewinside aColumn. Wrap the inner list inExpanded, give it a fixed height, or setshrinkWrap: true(only for short lists). - RenderFlex overflowed. A
RoworColumnis wider or taller than its space. Make the overflowing childExpanded/Flexible, or make the axis scroll. The RenderFlex overflow fix covers this in depth. shrinkWrap: trueon a long list. It forces the list to measure every child up front, killing the laziness you want. Prefer a bounded height or slivers instead.- Two scrollables fighting. Nesting independent scroll views causes janky gesture handling — use
CustomScrollViewwith slivers, orNestedScrollView, so one gesture drives the whole surface. - Lost scroll position on rebuild. Recreate the
ScrollControlleronly once (ininitState), never insidebuild.
Which scrolling widget should I use?
- Content occasionally taller than the screen →
SingleChildScrollView. - A list of items →
ListView.builder(add.separatedfor dividers). - A grid of items →
GridView.builder. - A collapsing header, or mixed lists and grids in one scroll →
CustomScrollViewwith slivers. - A header above tabbed lists →
NestedScrollView. - Refresh on drag-down → wrap the scrollable in
RefreshIndicator. - Detect or drive scrolling → attach a
ScrollController.
Frequently asked questions
What is a sliver in Flutter?
A sliver is a slice of a scrollable area that negotiates its size lazily with the surrounding viewport, so it can build only the visible portion. CustomScrollView scrolls a list of slivers — SliverAppBar, SliverList, SliverGrid — together as one surface.
When should I use CustomScrollView instead of ListView?
Use ListView or GridView when the whole page is one uniform list or grid. Use CustomScrollView when a single scroll combines different pieces — a collapsing header, then a grid, then a list — or needs pinned and floating headers.
Why is ListView.builder faster than a plain ListView?
ListView.builder builds items lazily and recycles them, so a 10,000-row list costs about the same as a short one. A ListView built from a children list constructs every child immediately, which is wasteful for long or dynamic lists.
How do I build a collapsing app bar?
Put a SliverAppBar first inside a CustomScrollView, give it an expandedHeight and a FlexibleSpaceBar, and set pinned: true to keep the toolbar visible or floating: true to let it reappear on scroll-up.
Further reads
Keep going with the tutorials that pair with this guide:
- Flutter Development Guide 2026 — the full Flutter hub.
- Flutter Layout Widgets: The Complete Guide — the constraints model that scrolling builds on.
- Flutter Material Widgets: The Complete Catalogue — the AppBar, Card, and ListTile widgets used above.
- Flutter Animations: The Complete Guide — animate the items these scroll views reveal.
- Dart Language for Flutter — the language behind every builder callback.
- Flutter Performance in 2026 — keep long lists scrolling at 60–120fps.
- How to Fix RenderFlex Overflowed in Flutter — the overflow error scrolling so often triggers.
Sources: Flutter documentation — Slivers, Scrolling, and the Widget catalog (docs.flutter.dev); the official "Using slivers to achieve fancy scrolling" and ListView/CustomScrollView API references. Verified against current stable Flutter.