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 
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> ); } 

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))}%` 

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.