Collapsible header tab view for React Native — with per-tab scroll memory, a jump-free header, and first-class adapters for FlatList, ScrollView, SectionList, FlashList v2 and LegendList.
Same gesture, same frame — iOS and Android side by side.
Screen.Recording.2026-06-16.at.1.25.28.AM.mp4
Built on react-native-reanimated, react-native-gesture-handler and react-native-pager-view. All animation runs on the UI thread. Works with Reanimated 3 and 4, old and New Architecture (FlashList adapter requires New Architecture), and Expo (including Expo Go).
import { Tabs } from 'react-native-collapsible-tab';
<Tabs.Container renderHeader={() => <MyHeader />}>
<Tabs.Tab name="posts" label="Posts">
<Tabs.FlatList data={posts} renderItem={renderPost} />
</Tabs.Tab>
<Tabs.Tab name="about" label="About">
<Tabs.ScrollView>
<About />
</Tabs.ScrollView>
</Tabs.Tab>
</Tabs.Container>The category's classic bugs come from one architectural decision in existing libraries: tying the header position directly to tab scroll offsets, then force-scrolling every tab to keep them in sync. This library decouples them:
- The header position is its own animated value, driven only by scroll deltas of the active tab. Tab switches never move the header — no flicker, no jump, no ghost blank space.
- Each tab keeps its own scroll offset (keyed by tab name). Switching back to a tab restores exactly where you were. Only the incoming tab is adjusted, only when needed to prevent a gap, and on the UI thread before the page becomes active.
- The header is drag-to-scrollable and its buttons work. A pan gesture on the header drives the active list (including release momentum) but only activates after 10px of vertical movement, so taps pass through to your touchables.
Pain points of react-native-collapsible-tab-view this library fixes or avoids by design:
| Pain point (issue refs) | Here |
|---|---|
| Buttons in header block scrolling (#361, #356, #449 — most-requested, never fixed) | Works: pan gesture + tap pass-through |
| Blank space / ghost header on tab switch (#259, #354, #490) | Structurally impossible: header decoupled from offsets |
| Non-ASCII tab names break sync (#354) | name is pure identity; display text goes in label |
| Breaks on every Reanimated/Expo bump (#463, #491, #453, #400) | Public Reanimated APIs only; no shared-value reads during render; v3 & v4 compatible |
lazy={false} ignored (#472) |
Honest: lazy defaults to false and means it |
| Jumping to a far tab mounts every intermediate tab (#417) | Only the destination mounts; intermediates stay placeholders |
onTabChange fires for every intermediate tab (#430) |
Fires once, for the settled destination |
snapThreshold freezes screen on New Arch (#468) |
Snap is plain shared-value animation — no UI-thread scroll deadlock |
| FlashList: header won't collapse, short lists break (#400, #335, #446, #477) | Dedicated v2 adapter: real Animated.ScrollView under FlashList via renderScrollComponent, footer spacer instead of unsupported minHeight, mVCP disabled by default |
Can't wrap Tabs.Tab in your own component (#422) |
Children are duck-typed on the name prop, not element type |
| Dynamic tabs reset scroll positions (#259 et al.) | Offsets keyed by tab name survive add/remove |
| No scroll-to-top-all-tabs (#38, #267), no scroll position outside tabs (#359) | ref.scrollAllToTop(), useActiveTabScrollY() |
| Reading shared values during render → warnings, Jest loops (#453, #240) | Never done |
npm install react-native-collapsible-tab
# peer dependencies (you likely have them):
npx expo install react-native-reanimated react-native-gesture-handler react-native-pager-viewOptional list backends (only if you use their adapters):
npx expo install @shopify/flash-list # v2+, New Architecture only
npm install @legendapp/listYour app must be wrapped in <GestureHandlerRootView> (Expo Router does this for you).
Requirements: react-native-reanimated ≥ 3.6, react-native-gesture-handler ≥ 2, react-native-pager-view ≥ 6. iOS & Android (no web — pager-view is native-only).
import { Tabs } from 'react-native-collapsible-tab';
function Profile() {
return (
<Tabs.Container
renderHeader={() => <ProfileHeader />} // measured automatically
minHeaderHeight={insets.top} // px that stays visible when collapsed
lazy // mount tabs on first focus
snapThreshold={0.5} // optional: snap open/closed
>
<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>
);
}FlashList / LegendList come from subpath exports so the packages stay optional:
import { TabFlashList } from 'react-native-collapsible-tab/flash-list';
import { TabLegendList } from 'react-native-collapsible-tab/legend-list';| Prop | Type | Default | Description |
|---|---|---|---|
renderHeader |
() => ReactNode |
— | Collapsible header. Omit for a plain pinned tab bar. Height is measured — no headerHeight prop needed. |
renderTabBar |
(props: TabBarRenderProps) => ReactNode |
DefaultTabBar |
Custom tab bar. |
minHeaderHeight |
number |
0 |
Header px that stays visible when fully collapsed (e.g. safe-area top). |
headerBackgroundColor |
string |
'#fff' |
Solid backing behind header + tab bar (see Translucent headers). |
headerContainerStyle |
StyleProp<ViewStyle> |
— | Extra styles on the animated header wrapper. |
containerStyle |
StyleProp<ViewStyle> |
— | Styles for the outer container. |
initialTabName |
string |
first tab | Tab to focus on mount. |
lazy |
boolean |
false |
Mount tab content on first focus. Swiping pre-mounts the neighbor; tapping a far tab mounts only the destination. |
renderLazyPlaceholder |
({ name, index }) => ReactNode |
null |
Shown for unmounted lazy tabs. |
revealHeaderOnScroll |
boolean |
false |
Any upward scroll reveals the header immediately (Twitter-style). Default: header re-appears when content scrolls back to it. |
snapThreshold |
number | null |
null |
When set (0..1), a header released mid-collapse animates fully open or closed. |
onIndexChange |
(index: number) => void |
— | Fires when a tab switch settles. |
onTabChange |
({ prevIndex, index, prevTabName, tabName }) => void |
— | Same timing, richer payload. Never fires for intermediate pages. |
pagerProps |
PagerView props |
— | Escape hatch to the underlying pager (keyboardDismissMode, overdrag, ...). |
Ref (useRef<CollapsingTabsRef>): jumpToTab(name, animated?), setIndex(index, animated?), getFocusedTab(), getCurrentIndex(), scrollToTop(animated?), scrollAllToTop().
| Prop | Type | Description |
|---|---|---|
name |
string |
Stable identity (scroll memory, jumpToTab). Keep it ASCII-simple; localized text goes in label. |
label |
string? |
Display text for the tab bar. Defaults to name. |
lazy |
boolean? |
Per-tab override of the container's lazy. |
swipeEnabled |
boolean? |
While this tab is focused, disables pager swiping (for horizontal carousels inside). |
You can wrap Tabs.Tab in your own component — the container detects children by their name prop, not element type. Just forward name (and children) to the element you return.
Drop-in replacements, same props as the underlying component (minus onScroll, which the adapter owns):
Tabs.ScrollViewTabs.FlatListTabs.SectionListTabFlashListfromreact-native-collapsible-tab/flash-list— FlashList v2 (New Architecture only).maintainVisibleContentPositionis disabled by default (it issues animated corrective scrolls that fight tab-switch sync); pass your own to opt back in.TabLegendListfromreact-native-collapsible-tab/legend-list
Each adapter automatically: pads content below the header + tab bar, guarantees short content can still fully collapse the header, restores saved offsets when a lazy tab mounts, sets sensible scrollIndicatorInsets / progressViewOffset, and feeds the header collapse + snap logic.
Building your own adapter? The contract is small — see useTabContentStyle, useRegisterTabList, useRestoreTabOffset, useTabScrollLifecycle and the LegendList.tsx source (~100 lines).
All hooks must be used inside <Tabs.Container> (header, tab bar, and tab content all qualify).
| Hook | Returns | Use for |
|---|---|---|
useHeaderScrollY() |
SharedValue<number> px collapsed |
Header animations (parallax, fade) |
useCollapseProgress() |
SharedValue<number> 0..1 |
Normalized header animations |
useHeaderMeasurements() |
{ top: SharedValue, height: number } |
Migration-compatible with collapsible-tab-view |
useCurrentTabScrollY() |
SharedValue<number> |
Raw offset of the tab you're inside |
useActiveTabScrollY() |
SharedValue<number> |
Raw offset of the focused tab, usable in the header |
useFocusedTab() |
SharedValue<string> |
Focused tab name on the UI thread |
useAnimatedTabIndex() |
SharedValue<number> |
Fractional pager position (tab bar indicators) |
useIsTabFocused(name) / useTabIndex() |
boolean / number |
JS-state focus (re-renders on switch) |
Used when you don't pass renderTabBar. Accessible (tablist/tab roles, selected state) and stylable: scrollable (default true; false = equal-width tabs), backgroundColor, activeColor, inactiveColor, indicatorColor, style, tabStyle, labelStyle, indicatorStyle, renderLabel.
The example/ folder is a standalone Expo app (SDK 54, New Architecture, runs in Expo Go) with one screen per feature: basic, snap, reveal-on-scroll, lazy, custom tab bar, dynamic tabs + imperative ref, SectionList, FlashList v2 and LegendList.
cd example
npm install
npx expo startThe example resolves the library from ../src via Metro config, so library edits hot-reload without a build step.
Header collapse rules. Scrolling down collapses the header in sync. Scrolling up (default) keeps it collapsed until the content top reaches it again, then expands in sync — so the header never detaches from content. With revealHeaderOnScroll, any upward delta expands it immediately. Snap (when enabled) animates whichever transition keeps content gapless.
Translucent headers. The header needs a solid headerBackgroundColor because per-tab scroll memory means deep-scrolled content can legitimately sit underneath an expanded header. That trade is what makes tab switches jump-free.
Sticky section headers stick to the real viewport top, which sits under the collapsible header until it collapses. This matches native sticky behavior; design section headers with that in mind.
Sticky / pinned header content. A custom header animation should fade or scale in place — don't translate header content upward as it collapses, or it rides past minHeaderHeight into the safe-area / status-bar region and overlaps it. Likewise, content that must stay visible while the header collapses (a reading-progress bar, a search field) belongs in the tab bar (renderTabBar), not the header — the header scrolls away, the tab bar stays pinned.
Gotcha — scroll offsets go negative on overscroll. useCurrentTabScrollY() / useActiveTabScrollY() return the list's raw contentOffset.y, which goes negative on iOS when the user bounces past the top. (Android doesn't bounce — its offset stays clamped at 0, see below.) If you map a scroll offset to a width, opacity, or progress, clamp the low end too — not just the high end:
// ❌ negative offset → negative width; during bounce-back the bar can flash full
width: `${Math.min((scrollY.value / TOTAL) * 100, 100)}%`
// ✅ clamp both ends so overscroll reads as 0%
width: `${Math.max(0, Math.min((scrollY.value / TOTAL) * 100, 100))}%`Setting bounces={false} hides the symptom, but clamping is the real fix and keeps the native bounce.
Gotcha — pull-to-refresh is platform-split. Because content is padded below the header, the native RefreshControl spinner renders at the content origin — tucked behind the header. The robust recipe differs by platform, because the two overscroll differently:
- Android — lists don't bounce (the offset stays clamped at 0; you get a stretch/glow). An offset-driven custom pull therefore can't work here. Use the native
RefreshControland let the adapter's defaultprogressViewOffset(= header height) push its spinner below the header, or set it yourself. - iOS — lists bounce, so
contentOffset.ygoes negative past the top. Read that pull distance fromuseCurrentTabScrollY()and drive your own spinner pinned in the visible area (below the header) — the native iOS spinner would be hidden behind the header.
const scrollY = useCurrentTabScrollY(); // negative on iOS = pull distance
const { height } = useHeaderMeasurements();
<Tabs.FlatList
refreshControl={
Platform.OS === 'android'
? <RefreshControl refreshing={busy} onRefresh={refresh}
progressViewOffset={height} />
: undefined // iOS: render a custom spinner driven by scrollY instead
}
/>Web is not supported (react-native-pager-view is native-only).
MIT