Back to notes

June 5, 2026 · 5 min read

Handling Pinned Headers and Inner Scrolling in Flutter NestedScrollView

When a pinned header, PageView, and inner lists appear together, SliverOverlapAbsorber and SliverOverlapInjector should be used to build the correct scrolling relationship.

Problem

There is a common kind of Flutter page: a top introduction, search area, or shortcut section; a pinned tab/header in the middle; and content below that can switch horizontally. Each tab may contain a list, a grid, or custom scrollable content.

The structure is roughly:

Outer vertical scroll
  top content
  pinned header / tabs

Inner area
  PageView
    page 1: ListView / GridView / CustomScrollView
    page 2: ListView / GridView / CustomScrollView

The most common issue is that after the header becomes pinned, the inner list keeps scrolling upward and the content slips under the header. Visually, it looks as if the tab is covering the content.

A common first reaction is to add padding:

GridView.builder(
  padding: const EdgeInsets.only(top: 80),
  // ...
)

Or to wrap the PageView with padding:

Padding(
  padding: const EdgeInsets.only(top: 80),
  child: PageView(
    children: [
      GridView.builder(
        // ...
      ),
    ],
  ),
)

These approaches can sometimes look aligned for a moment, but they are not stable. Once the header height changes, the tab switches, or the page scrolls quickly, the problem can easily come back.

Root Cause

The root cause is not a lack of padding. It is that the two scrolling regions are not synchronized correctly.

Inside NestedScrollView, there are usually two scrolling worlds:

Outer scroll: handles the top content and pinned header
Inner scroll: handles each tab/page's list content

When the outer SliverPersistentHeader(pinned: true) becomes pinned, it occupies a piece of space at the top. But the inner GridView or ListView does not know that this space exists.

If the code is written directly like this:

NestedScrollView(
  headerSliverBuilder: (context, innerBoxIsScrolled) {
    return [
      SliverPersistentHeader(
        pinned: true,
        delegate: YourHeaderDelegate(),
      ),
    ];
  },
  body: PageView(
    children: [
      GridView.builder(
        // ...
      ),
    ],
  ),
)

The outer header and inner list have not established an overlap relationship. The inner list only knows that it should scroll to the top; it does not know that the top is already occupied by the pinned header.

So this should not be solved by manually checking whether the header is pinned, or by guessing a fixed padding value. A more stable solution is to use the overlap mechanism Flutter provides for NestedScrollView.

Solution

Flutter provides two key slivers:

  • SliverOverlapAbsorber
  • SliverOverlapInjector

You can think of them this way:

The outer layer uses SliverOverlapAbsorber to record the overlap produced by the header, and the inner layer uses SliverOverlapInjector to inject that overlap back into the layout.

In other words:

Outer header: this is how much space I occupy after being pinned
Inner list: I will start layout from the correct position

A stable structure looks like this:

NestedScrollView(
  headerSliverBuilder: (context, innerBoxIsScrolled) {
    return [
      SliverToBoxAdapter(
        child: topContent,
      ),
      SliverOverlapAbsorber(
        handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
        sliver: SliverPersistentHeader(
          pinned: true,
          delegate: YourPinnedHeaderDelegate(
            child: pinnedTabs,
          ),
        ),
      ),
    ];
  },
  body: Builder(
    builder: (nestedContext) {
      return PageView.builder(
        itemBuilder: (context, index) {
          return CustomScrollView(
            slivers: [
              SliverOverlapInjector(
                handle: NestedScrollView
                    .sliverOverlapAbsorberHandleFor(nestedContext),
              ),
              const SliverToBoxAdapter(
                child: SizedBox(height: 12),
              ),
              SliverPadding(
                padding: const EdgeInsets.symmetric(horizontal: 24),
                sliver: SliverGrid(
                  // ...
                ),
              ),
            ],
          );
        },
      );
    },
  ),
)

There are several key points here.

Wrap the Outer Pinned Header With Absorber

SliverOverlapAbsorber should wrap the sliver that produces the overlap. In most cases, that is the SliverPersistentHeader with pinned: true.

SliverOverlapAbsorber(
  handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
  sliver: SliverPersistentHeader(
    pinned: true,
    delegate: YourPinnedHeaderDelegate(),
  ),
)

Use CustomScrollView for Inner Pages

If the body contains a PageView, do not let each page directly return a GridView or ListView. It is better for each page to return a CustomScrollView, so that SliverOverlapInjector can be placed at the beginning of the sliver list.

CustomScrollView(
  slivers: [
    SliverOverlapInjector(
      handle: NestedScrollView.sliverOverlapAbsorberHandleFor(nestedContext),
    ),
    SliverGrid(
      // ...
    ),
  ],
)

Put Visual Spacing After the Injector

If you need some spacing between the content and the pinned header, do not add padding outside the PageView. That position affects the horizontal paging area, not the real scroll start of each page’s vertical content.

A better place is right after SliverOverlapInjector:

const SliverToBoxAdapter(
  child: SizedBox(height: 12),
)

This value can be extracted as a UI constant:

static const double pageTopGap = 12;

Then future visual adjustments only change this constant, not the scroll structure.

Common Mistakes

Adding Top Padding to PageView

PageView handles horizontal switching. It does not handle top synchronization for each page’s inner content. Putting padding outside PageView often ties the horizontal swipe area to the vertical content position, which becomes hard to maintain later.

Guessing a Padding for GridView

Writing padding: EdgeInsets.only(top: 80) on a GridView only pushes the content down visually. It does not solve the overlap synchronization between the outer header and inner scroll.

Once the header height changes, or different tabs use different content structures, that padding becomes a new problem.

Using the Wrong Context to Get the Handle

NestedScrollView.sliverOverlapAbsorberHandleFor(context) must receive a context below the NestedScrollView.

If the body needs the handle, usually create a nestedContext with Builder:

body: Builder(
  builder: (nestedContext) {
    return CustomScrollView(
      slivers: [
        SliverOverlapInjector(
          handle: NestedScrollView
              .sliverOverlapAbsorberHandleFor(nestedContext),
        ),
      ],
    );
  },
)

Otherwise, you may see an error like:

NestedScrollView.sliverOverlapAbsorberHandleFor must be called with a context that contains a NestedScrollView.

Debugging Checklist

When a pinned header and inner list are misaligned, check in this order:

CheckFocus
Is NestedScrollView used?Outer and inner scroll areas need to cooperate
Is there a SliverPersistentHeader(pinned: true)?A pinned header creates top overlap
Does the body contain another scrollable widget?Such as PageView, ListView, or GridView
Is the pinned header wrapped by SliverOverlapAbsorber?The outer layer needs to record overlap
Is the first inner sliver SliverOverlapInjector?The inner layer needs to consume overlap
Does the inner page use CustomScrollView?It makes it easier to compose injector, gap, and content slivers
Is visual spacing placed after the injector?Do not put it outside PageView

If absorber and injector do not appear as a pair, fix the structure first instead of tuning padding.

Summary

For this kind of layout, one sentence is enough to remember the structure:

Use Absorber for the outer pinned header, and Injector for inner scrolling.
PageView handles horizontal switching; CustomScrollView handles vertical content.

NestedScrollView itself is not the problem. The problem is usually that the outer header and inner list have not established an overlap relationship.

Once the structure is correct, many problems that look like “covering”, “slipping under”, or “padding not working” become controllable sliver layout issues.