DEV Community

李亚楠
李亚楠

Posted on

Building Twitter-style Collapsible Tabs in React Native (the easy way)

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:

  1. Wrestling with Animated.event, onScroll listeners and a manual translateY (slow on Android), or
  2. 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.

demo


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
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

That's the whole thing. The wrapped <FlatList> automatically:

  • pushes its content below the header (no manual paddingTop math),
  • forwards scroll events to the shared collapse animation,
  • still calls your own onScroll if you pass one,
  • keeps its own scroll offset per tab.

⚠️ The one rule: the name prop on <Tab> and the name prop 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>
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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

If this saves you an afternoon, a ⭐ on GitHub goes a long way. Happy collapsing!

Top comments (0)