DEV Community

Cover image for Why collapsible tab headers jump in React Native (and how to fix it)
Jassi Singh
Jassi Singh

Posted on

Why collapsible tab headers jump in React Native (and how to fix it)

You've seen this screen a hundred times: a profile header, a row of tabs underneath (Posts, Media, Likes), and the header collapses as you scroll. Twitter, Instagram, Spotify artist pages.

Now try this on most implementations:

  1. Scroll Posts down a few screens. The header collapses.
  2. Switch to Media, which is still at the top.
  3. Watch the header jump: snap open, snap closed, or leave a gap.

I hit this enough times that I ended up writing my own library. Here's what's actually going on.

The root cause: the header is derived from the active tab's scroll

The common approach is simple: headerTranslateY = -clamp(activeTab.scrollY, 0, headerHeight).

It works perfectly with one tab. With several, every tab has its own scroll offset, and the header position is a function of whichever tab is active. Switch tabs and the input changes instantly, so the output (your header) teleports.

Libraries usually patch around it by force-syncing scroll offsets on every tab switch (scrolling the new tab to match the header). That's where you get flicker, lost scroll positions, and fights with list virtualization.

The fix: decouple the header from tab offsets

Instead of deriving the header from a scroll offset, make the header position its own piece of state (a Reanimated shared value), and drive it with scroll deltas:

  • Scrolling down collapses the header in sync with content.
  • Scrolling up keeps the header collapsed until the content's top edge reaches it, then expands it in sync. The header never detaches from content and never leaves a gap.
  • Switching tabs doesn't touch the header at all. Each tab keeps its own scroll position, and the header stays exactly where it was.

No offset-syncing, no jump, and every tab remembers where you left it.

The trade-off (there's always one)

Because each tab keeps its own scroll memory, a deep-scrolled tab can have content sitting underneath an expanded header. So the header needs a solid background; translucent headers don't work with this model. I think that's the right trade for jump-free switching, but it's worth knowing upfront.

In practice

▶️ Watch the demo: switching tabs with zero header jump

I packaged this approach as react-native-collapsible-tab. It's built on Reanimated, Gesture Handler and Pager View, with all animation on the UI thread.

npm install react-native-collapsible-tab
npx expo install react-native-reanimated react-native-gesture-handler react-native-pager-view
Enter fullscreen mode Exit fullscreen mode
import { Tabs } from 'react-native-collapsible-tab';

function Profile() {
  return (
    <Tabs.Container
      renderHeader={() => <ProfileHeader />} // height measured automatically
      minHeaderHeight={insets.top}           // stays visible when collapsed
      lazy                                   // mount tabs on first focus
      snapThreshold={0.5}                    // settle fully open/closed on release
    >
      <Tabs.Tab name="posts" label="Posts">
        <Tabs.FlatList
          data={posts}
          renderItem={({ item }) => <Post post={item} />}
          keyExtractor={(item) => item.id}
        />
      </Tabs.Tab>
      <Tabs.Tab name="media" label="Media">
        <Tabs.ScrollView>
          <MediaGrid />
        </Tabs.ScrollView>
      </Tabs.Tab>
    </Tabs.Container>
  );
}
Enter fullscreen mode Exit fullscreen mode

A few things I cared about while building it:

  • Twitter-style reveal: revealHeaderOnScroll brings the header back on any upward scroll.
  • Snap without deadlocks: snap is a plain shared-value animation, not offset-driven scrolling, which is known to lock up the UI thread on the New Architecture.
  • FlashList v2 and LegendList adapters, plus FlatList, ScrollView and SectionList.
  • Windowed memory: with React 19.2's <Activity>, windowConfig={{ ahead: 1, behind: 1 }} keeps 60 tabs from all staying mounted. Only the focused one and its neighbours stay live, and scroll positions survive.

One gotcha that bites everyone: negative scroll offsets

If you animate anything from scroll position (a progress bar, a fade), remember that on iOS contentOffset.y goes negative when the list bounces past the top. Android clamps at 0. Clamp both ends:

// ❌ negative offset → negative width during bounce
width: `${Math.min((scrollY.value / TOTAL) * 100, 100)}%`

// ✅ overscroll reads as 0%
width: `${Math.max(0, Math.min((scrollY.value / TOTAL) * 100, 100))}%`
Enter fullscreen mode Exit fullscreen mode

bounces={false} hides the symptom; clamping is the actual fix.


Docs: rnct.scannertechs.com · GitHub: JassiSingh08/react-native-collapsible-tab

If you've fought this bug with a different approach, I'd love to hear how you solved it in the comments.

Top comments (0)