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:
- Scroll Posts down a few screens. The header collapses.
- Switch to Media, which is still at the top.
- 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:
revealHeaderOnScrollbrings 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.
Top comments (0)