The problem
Almost every social / commerce app eventually needs the same screen:
A large header on top, a row of tabs underneath, and as you scroll, the header slides up and the tabs stick to the navigation bar. Swipe horizontally to switch tabs — and each tab remembers its own scroll position.
Twitter has it. Instagram has it. YouTube has it. Spotify, Airbnb, every food-delivery app has it.
And every time I had to build it in React Native, I ended up either:
- Wrestling with
Animated.event,onScrolllisteners and a manualtranslateY(slow on Android), or - Pulling in a heavy library and fighting its API to do the one thing I actually needed.
So I built @orionarm/react-native-collapse-tabs — a small, focused library that does exactly this and nothing more.
What it does
- 📜 Collapsible header — collapses smoothly as you scroll the inner list.
- 🗂 Swipeable tabs — horizontal paging via
react-native-pager-view(native, not JS). - 🎚 Per-tab scroll state — every tab remembers its own scroll position.
- 🪄 Smooth tab switching — the header tweens between tabs instead of jumping.
- 🛡 Overscroll-safe — pull-to-refresh doesn't push the header off screen.
- ⚡ Reanimated v3 worklets — every animation runs on the UI thread, 60 fps.
- 🧩 Drop-in
FlatList/ScrollView— wrapped versions handle the scroll plumbing.
Install
npm install @orionarm/react-native-collapse-tabs \
react-native-pager-view \
react-native-reanimated \
react-native-gesture-handler
Add the Reanimated babel plugin to the end of your babel.config.js:
module.exports = {
presets: ['module:metro-react-native-babel-preset'],
plugins: ['react-native-reanimated/plugin'], // must be last
};
For Expo: npx expo install react-native-reanimated react-native-pager-view react-native-gesture-handler.
Hello, profile screen
import React from 'react';
import { Text, View, StyleSheet } from 'react-native';
import {
CollapseTabs,
Tab,
FlatList,
} from '@orionarm/react-native-collapse-tabs';
const HEADER_HEIGHT = 220;
const TAB_BAR_HEIGHT = 44;
export default function ProfileScreen() {
return (
<CollapseTabs
headerHeight={HEADER_HEIGHT}
tabBarHeight={TAB_BAR_HEIGHT}
renderHeader={() => (
<View style={styles.header}>
<Text style={styles.title}>@yanan_orionarm</Text>
<Text style={styles.bio}>Building React Native open source.</Text>
</View>
)}
>
<Tab name="Posts">
<FlatList
name="Posts"
data={posts}
renderItem={({ item }) => <PostRow item={item} />}
keyExtractor={(p) => p.id}
/>
</Tab>
<Tab name="Likes">
<FlatList
name="Likes"
data={likes}
renderItem={({ item }) => <PostRow item={item} />}
keyExtractor={(p) => p.id}
/>
</Tab>
</CollapseTabs>
);
}
That's the whole thing. The wrapped <FlatList> automatically:
- pushes its content below the header (no manual
paddingTopmath), - forwards scroll events to the shared collapse animation,
- still calls your own
onScrollif you pass one, - keeps its own scroll offset per tab.
⚠️ The one rule: the
nameprop on<Tab>and thenameprop on the inner<FlatList>must match. That's how the library wires each tab's scroll state.
Custom tab bar in 10 lines
The default tab bar is intentionally minimal. Want pills, gradients, badges? Pass renderTabBar:
<CollapseTabs
headerHeight={220}
tabBarHeight={44}
renderTabBar={({ tabNames, indexDecimal, onTabPress }) => (
<MyPillsBar
tabs={tabNames}
indexDecimal={indexDecimal} // SharedValue<number>, drive your own animation
onPress={onTabPress}
/>
)}
>
{/* tabs */}
</CollapseTabs>
indexDecimal is a Reanimated SharedValue that smoothly interpolates between tab indices as the user swipes, so the underline / pill indicator stays in sync with the gesture in real time.
Animate the header too
The header receives the same animated state, so you can parallax, fade, or scale it:
import Animated, { useAnimatedStyle, interpolate } from 'react-native-reanimated';
import { useTabsContext } from '@orionarm/react-native-collapse-tabs';
function FancyHeader() {
const { headerTranslateY, headerHeight } = useTabsContext();
const avatarStyle = useAnimatedStyle(() => ({
transform: [
{
scale: interpolate(
headerTranslateY.value,
[0, -headerHeight],
[1, 0.5],
'clamp'
),
},
],
}));
return (
<View style={{ flex: 1 }}>
<Animated.Image source={avatar} style={[styles.avatar, avatarStyle]} />
{/* ...rest of header */}
</View>
);
}
Why not just use react-native-collapsible-tab-view?
Honest answer: it's a great library. If you're already using it and happy, stay there.
I built react-native-collapse-tabs because I wanted:
| Goal | Result |
|---|---|
| Smaller API surface | 4 exported components + 1 hook |
No react-native-tab-view layer |
Pager view used directly, fewer abstractions |
Direct access to SharedValues |
useTabsContext() exposes everything, no forking to customize |
Drop-in FlatList / ScrollView |
No createCollapsibleFlatList factory, just import and use |
| No native code, Expo-friendly | Works on Expo SDK 49+ without prebuild |
It's a different set of trade-offs, not a "better" library.
Links
- 📦 npm: https://www.npmjs.com/package/@orionarm/react-native-collapse-tabs
- 🔧 GitHub: https://github.com/orion-arm-ai/react-native-collapse-tabs
- 🐛 Issues / feature requests welcome.
If this saves you an afternoon, a ⭐ on GitHub goes a long way. Happy collapsing!

Top comments (0)