react-native-collapsible-tabs-native
v0.9.0
Published
Native collapsible tabs for React Native (Fabric): collapsing header + pinned tab bar over a native pager, driven by UIKit/ViewPager2 so header and list move in the same frame.
Maintainers
Readme
react-native-collapsible-tabs-native
Native collapsible tabs for React Native: a collapsing header with a
pinned tab bar over a swipeable tab pager — the Instagram / Twitter profile
layout — where the collapse is driven by native code (UIKit on iOS,
ViewPager2 on Android), not by a JS or Reanimated worklet.
Because the header is translated inside the same native scroll callback that moves the list, the header, tab bar and list content always move in the same frame. There is no per-frame JS work in the scroll path, so heavy JS load cannot desynchronise them.
Your header, tab bar and tab pages are ordinary React components. The native side only owns geometry and gestures.
Why not a JS implementation?
JS collapsible-tab libraries (including ones built on Reanimated) move the list content from the native scroll view but move the header from an animation callback fed by that scroll's event. Two update paths mean the header runs at least one frame behind the list — visible as a gap opening between the tab bar and the content on a fast fling, worst on Android and on iOS whenever the JS thread is busy. This library removes the second update path instead of trying to keep up with it.
Measured, not asserted: on a mid-range 120 Hz Android phone with the JS thread 60 % busy, flinging a 500-row list gives 0 % janky frames and a p99 of 12 ms — see docs/benchmarks.md for the method, the device and how to re-run it.
What it does
- Collapsing header + pinned tab bar over a native horizontal pager, in
frame-perfect sync with the active list (native
UIScrollViewDelegate/View.OnScrollChangeListener). The tab bar can also collapse away with the header (pinTabBar={false}). - Any vertical list that renders a React Native
ScrollViewworks as a tab page:ScrollView,FlatList,SectionList, FlashList, LegendList — wrapped withcreateTabListso its content is padded under the header. - Vertical drags on the header (or tab bar) scroll the active page, with a display-link-driven fling on iOS and native event forwarding on Android. Horizontal lists inside the bands — a chip row in the header, the tab strip itself — keep their own sideways gestures, while their vertical drags still scroll the page.
- Swipe between tabs; a tab page mounts the moment it peeks into view (not when the swipe settles), and a freshly-mounted or neighbouring page is aligned to the current header offset before it becomes visible.
- Optional per-frame swipe position (
onPageScroll) so a custom tab bar can interpolate its indicator with the finger — on the UI thread via a Reanimated worklet, so the JS thread still does nothing per frame. - Tabs with little or no content (an empty state, one row) still scroll the
header away, because native gives those pages exactly the scroll range they
lack (
allowFullCollapse, on by default). - Container-level pull-to-refresh (
refreshing/onRefresh): the scroll view bounce on iOS,SwipeRefreshLayouton Android — threshold, resting offset, colours and size are props, and the native spinner can be hidden for a custom one. onCollapsedChangefires once per threshold crossing (not per frame) — use it to swap fixed chrome, e.g. reveal a title in your own top bar.- An imperative
ref—scrollToTop,setIndex,collapse,expand— for "tap the active tab again" and friends. - A header that reacts to its own collapse (
onHeaderOffsetChangeas a Reanimated worklet — avatar shrink, title fade, parallax, on the UI thread), and a pinned bottom strip (headerMinHeight). - Presses under a finger that scrolled are cancelled correctly: a swipe that
ends on a
Pressabledoes not trigger it; a deliberate tap does. - A minimal default
TabBar(equal-width labels + underline, colours via props), or bring your own withrenderTabBar. - TypeScript types throughout.
What it does not do
Read this before choosing the library — these are real constraints, not roadmap fine print:
- New Architecture (Fabric) only. No Paper support. React Native ≥ 0.80 (developed and tested on RN 0.83).
- Per-frame positions are opt-in and meant for a Reanimated worklet: the
pager's swipe position (
onPageScroll), the bands' offset (onHeaderOffsetChange) and the active list's own offset (onScrollOffsetChange). Nothing per-frame reaches the JS thread unless you pass a plain function. There is no synchronous "read the position now" call, and no built-in save/restore across remounts yet; you can drive positions through theref(scrollToTop,collapse,expand,setIndex). onPageScrollwith RNAnimated.event+useNativeDriver: truedoes not work, and cannot: on Fabric, native-driven animated events only reach the animated module through a deprecated back-channel React Native special-cases for its own ScrollView and has marked for removal. Use a ReanimateduseEventworklet (UI thread, no per-frame JS) or accept a plain JS callback.- The header collapses as one band.
headerMinHeightkeeps its bottom strip pinned above the tab bar, andpinTabBar={false}lets the tabs scroll away with it — but there is no arbitrary "this child pins at the top while the rest scrolls away" slot inside the header. Sticky section headers inside a page's list are a list feature (FlashList v2stickyHeaderIndices) and work under the bands withonHeaderOffsetChange— see the FAQ. - Horizontal swipes that start on the header are deliberately inert (they neither page nor scroll). Swipe on the content or use the tab strip. The tab-bar band scrolls its own content horizontally if you render one that does.
- A tab whose content is too short is given the scroll range it lacks, so it
collapses like any other (
allowFullCollapse, on by default). Set it tofalsefor the Twitter-style alternative, where the header instead eases back to whatever offset that tab can hold — but note that a page with no scroll range at all cannot be scrolled or collapsed, and drags in the blank area below its content do nothing. - Pull-to-refresh is the platform's own indicator (an
UIActivityIndicatorViewon iOS,SwipeRefreshLayouton Android). Threshold, resting offset, colours and size are props; a fully custom indicator component driven natively is not supported yet — on iOS you can hide the native one and draw your own fromonHeaderOffsetChange'spull. - Pages stay mounted once visited (
lazyonly defers the first mount). A page mounts as soon as any sliver of it peeks in during a swipe, not when the swipe settles — so its data fetch starts if you drag toward it and change your mind. - No web / Expo Go support (native code; works in Expo dev clients / prebuild).
Example
example/ is a bare RN app wired straight to the repo's src/ and native
code (no copying) — cd example && yarn install, then the usual yarn ios /
yarn android. See example/README.md.
Install
yarn add react-native-collapsible-tabs-native
cd ios && pod installAutolinked on both platforms. Requires React Native ≥ 0.80 with the New Architecture enabled (the default since 0.76).
The package ships untranspiled TypeScript (Metro handles it natively). Jest
does not — if your tests import a screen that uses this package, allow it
through transformIgnorePatterns in your Jest config:
transformIgnorePatterns: [
'node_modules/(?!(react-native|@react-native|react-native-collapsible-tabs-native)/)',
],react-native-reanimated is an optional peer: it is only required (or
even imported) if you pass a Reanimated useEvent worklet to onPageScroll.
Usage
import { useState } from 'react';
import { FlashList } from '@shopify/flash-list';
import {
CollapsibleTabView,
createTabList,
TabScrollView,
} from 'react-native-collapsible-tabs-native';
const TabFlashList = createTabList(FlashList);
const routes = [
{ key: 'posts', title: 'Posts' },
{ key: 'about', title: 'About' },
];
function Profile() {
const [index, setIndex] = useState(0);
const [refreshing, setRefreshing] = useState(false);
const [collapsed, setCollapsed] = useState(false);
return (
<>
<TopBar title={collapsed ? user.name : undefined} />
<CollapsibleTabView
navigationState={{ index, routes }}
onIndexChange={setIndex}
renderHeader={() => <ProfileHeader user={user} />}
renderScene={({ route }) =>
route.key === 'posts' ? (
<TabFlashList data={posts} renderItem={renderPost} numColumns={3} />
) : (
<TabScrollView>
<About user={user} />
</TabScrollView>
)
}
refreshing={refreshing}
onRefresh={async () => {
setRefreshing(true);
await reload();
setRefreshing(false);
}}
collapseThreshold={120}
onCollapsedChange={setCollapsed}
tabBarProps={{ activeColor: '#fff', inactiveColor: '#888', indicatorColor: '#BAFF11' }}
/>
</>
);
}The one rule
Tab bodies must pad their content by the header + tab-bar height — the
bands are overlaid on the pager, not stacked above it. createTabList (and
the bundled TabScrollView / TabFlatList) do this for you; or read
useCollapsibleTabs().contentPaddingTop and apply it yourself.
API
<CollapsibleTabView>
| prop | type | notes |
| --- | --- | --- |
| navigationState | { index, routes } | routes are { key, title } (react-native-tab-view shape) |
| renderScene | ({ route }) => ReactNode | one page per route |
| onIndexChange | (index) => void | tab press or swipe settled |
| renderHeader | () => ReactNode | the collapsing header |
| renderTabBar | ({ routes, index, onIndexChange }) => ReactNode | defaults to TabBar. Want the tabs to scroll away with the header? Keep them here and pass pinTabBar={false} — don't move them into renderHeader (pages clear the tab-bar band's height either way, and an empty band gives the shell nowhere to put the tabs back without landing on content) |
| tabBarProps | TabBarProps | colours/onTabPress for the default TabBar; ignored with renderTabBar |
| refreshing / onRefresh | boolean / () => void | container-level pull-to-refresh; keep refreshing true until done |
| refreshThreshold | number (dp) | pull distance that triggers a refresh on release. Default 70 |
| refreshIndicatorOffset | number (dp) | how far below the top the spinner rests while refreshing. Default 60 |
| refreshTintColor / refreshBackgroundColor | ColorValue | spinner colour, and the disc behind it (Android's native look, drawn on iOS too) |
| refreshIndicatorSize | 'default' \| 'large' | spinner size |
| refreshIndicatorHidden | boolean | hide the native spinner and draw your own from onHeaderOffsetChange's pull — the header band itself translates by the pull, so anything positioned above its top edge rides into view. iOS; on Android the spinner is invisible but there is no pull value. The gesture and onRefresh still fire |
| collapseThreshold | number (dp) | crossing point for onCollapsedChange |
| collapseMode | 'classic' \| 'direction' | 'classic' (default): header returns as content nears the top. 'direction': any up-scroll reveals it, any down-scroll hides it |
| headerMinHeight | number (dp) | default 0. Bottom strip of the header that stays pinned above the tab bar (a search bar, a filter row) instead of scrolling away. The tab bar necessarily stays too, so pinTabBar={false} is ignored while this is > 0 |
| onHeaderOffsetChange | Reanimated useEvent handler, or (e) => void | the bands' live offset while they move — { offset, collapsibleHeight, pull } in dp; offset / collapsibleHeight is the 0..1 progress. For a header that reacts to its own collapse (avatar shrink, title fade, cover parallax). Same contract as onPageScroll: a worklet reads it on the UI thread; a plain function costs a JS call per frame. Emitted only while a handler is set, and only on change |
| onScrollOffsetChange | Reanimated useEvent handler, or (e) => void | the active list's live scroll offset — { index, offset }, dp from its content top (0 = header open, collapsibleHeight = collapsed, larger = scrolled on; negative on an over-drag), per frame while it moves and once when the active page changes. Same contract as onHeaderOffsetChange. For parallax deeper in the page, a scroll-to-top pill, a progress bar |
| pinTabBar | boolean | default true: the tab bar stays pinned at the top once the header is gone. false: the whole band, tabs included, collapses as part of the header |
| allowFullCollapse | boolean | default true. Tabs too short to scroll collapse the header anyway — native gives such a page exactly the scroll range it lacks; tabs with enough content are untouched. false restores the old behaviour |
| onCollapsedChange | (collapsed) => void | fires on crossings only |
| onPageScroll | Reanimated useEvent handler, or (e) => void | the pager's live swipe position, for a tab indicator that tracks the finger. Emitted only while a handler is set. A worklet reads it on the UI thread; a plain function costs a JS call per frame. The bundled TabBar already uses it (a plain function of yours is chained; a worklet takes the event over and the bundled indicator falls back to animating on settle) |
| swipeEnabled | boolean | default true |
| lazy | boolean | default true; mount a page on first visit |
| style | ViewStyle | shell container style |
| ref | CollapsibleTabsRef | imperative surface — see below |
ref — imperative API
const tabs = useRef<CollapsibleTabsRef>(null);
<CollapsibleTabView ref={tabs} … />
tabs.current?.scrollToTop(); // active page → top, header comes back
tabs.current?.scrollToTop({ index: 1, animated: false });
tabs.current?.setIndex(2, { animated: false }); // jump without the pager animation
tabs.current?.collapse(); // header (and unpinned tab bar) away
tabs.current?.expand();| method | what it does |
| --- | --- |
| scrollToTop({ index?, animated? }) | scroll a page's list to its top (default: the active page). The "tap the active tab again" affordance |
| setIndex(index, { animated? }) | move the pager; fires onIndexChange exactly like a swipe, so your controlled index stays the source of truth. Exists for the animated: false jump a prop change cannot express |
| collapse({ animated? }) | scroll the active list until the bands are fully collapsed |
| expand({ animated? }) | bring the bands back — 'classic' scrolls the list to its top (the header mirrors it), 'direction' animates the header alone, which that mode allows |
Every method goes through the collapse engine: the header is derived from
the active list's scroll position, so these move the list and let the header
follow rather than moving the header on its own (which would leave a gap under
the tab bar). allowFullCollapse guarantees even a short tab can honour
collapse().
Tracking the swipe (interpolating a tab indicator)
onPageScroll reports the pager's position (position + offset, i.e. page
index plus 0..1 towards the next) every frame while a handler is attached.
Read it in a Reanimated worklet and the JS thread stays idle:
import { useEvent, useSharedValue } from 'react-native-reanimated';
const progress = useSharedValue(-1); // -1 = the pager hasn't moved yet
const onPageScroll = useEvent<{ position: number; offset: number }>(
event => {
'worklet';
progress.value = event.position + event.offset;
},
['topPageScroll', 'onPageScroll'],
);
<CollapsibleTabView … onPageScroll={onPageScroll} renderTabBar={…} />Your tab bar then interpolates a single indicator between measured tab
positions from progress. Reanimated is an optional peer dependency: it is
only required (and only imported) if you pass a worklet handler.
Reacting to the collapse (avatar shrink, parallax…)
const progress = useSharedValue(0); // 0 open → 1 collapsed
const onHeaderOffsetChange = useEvent<{ offset: number; collapsibleHeight: number; pull: number }>(
e => {
'worklet';
progress.value = e.offset / Math.max(1, e.collapsibleHeight);
},
['topHeaderOffsetChange', 'onHeaderOffsetChange'],
);
<CollapsibleTabView onHeaderOffsetChange={onHeaderOffsetChange} renderHeader={() => <Header progress={progress} />} … />
// inside Header:
const avatarStyle = useAnimatedStyle(() => ({
transform: [{ scale: interpolate(progress.value, [0, 1], [1, 0.55], Extrapolation.CLAMP) }],
}));The shell writes the value from the same native callback that moves the bands, and the worklet styles read it on the UI thread — the header reacts without a JS frame. pull is the over-drag past the top on iOS (0 on Android, where the refresh layout owns it), for stretch effects.
Reading the list offset (parallax, scroll-to-top pill)
const y = useSharedValue(0);
const onScrollOffsetChange = useEvent<{ index: number; offset: number }>(
(e) => {
'worklet';
y.value = e.offset;
},
['topScrollOffsetChange', 'onScrollOffsetChange'],
);
const pillStyle = useAnimatedStyle(() => ({ opacity: y.value > 600 ? 1 : 0 }));
<CollapsibleTabView onScrollOffsetChange={onScrollOffsetChange} … />The value is the active list's own offset from its content top, read in the same native callback that moves the bands, so anything you derive from it moves in the same frame as the header. It fires once more whenever the active page changes, with that page's offset.
createTabList(List)
Wraps any list component that renders an RN ScrollView and forwards
contentContainerStyle, adding the shell's top padding. TabScrollView and
TabFlatList ship prebuilt:
const TabFlashList = createTabList(FlashList);
const TabLegendList = createTabList(LegendList);<TabBar>
The default strip: labels with an underline that tracks the finger during a
swipe; scrolls when the tabs overflow. Props: routes, index,
onIndexChange, onTabPress?, activeColor?, inactiveColor?,
indicatorColor?, backgroundColor?, style?, tabStyle?, labelStyle?,
scrollEnabled? (default true; pass false for equal-width tabs),
position? (an Animated.Value of the pager's continuous position —
CollapsibleTabView supplies it; from a custom renderTabBar, feed one from
onPageScroll).
<CollapsibleTabsShell>
The lower-level primitive if you don't want the tab-view-shaped API:
header / tabBar / pages[] / index / onIndexChange plus the same
collapse / refresh / event props, and the same ref.
useCollapsibleTabs()
{ isNativeShell, contentPaddingTop, activeIndex } — for custom tab bodies.
How it works
Fabric mounts your header, tab bar and pages as children of the native view;
the native side re-parents them by nativeID into slots: the two bands (the
header over the tab bar, inside one non-scrollable React Native ScrollView)
drawn above a horizontal pager (paging UIScrollView on iOS, ViewPager2 on
Android). The active page's vertical scroll view is located and observed
natively; its offset, clamped to the header height, becomes the bands' scroll
offset — applied in the same callback that moved the content. Driving a real
RN scroll view rather than translating the views is what keeps Pressables in
the bands working: Fabric's measure() only learns native positions through
ScrollView state, which RN updates on every scroll.
Neighbouring pages are pre-aligned during a swipe, and pages that mount late
are aligned as their content grows. When the shell takes over a gesture it
cancels React's in-flight touch, so buttons under the finger don't fire.
The native code is small and commented —
ios/NativeCollapsibleTabsContent.swift
and
android/src/main/java/com/collapsibletabs/ui/CollapsibleTabsHostView.kt
are the two files that matter.
FAQ
Is this a drop-in replacement for react-native-collapsible-tab-view or
react-native-tab-view?
No. The navigationState / renderScene shape is intentionally similar so
migration is mechanical, but the props are not identical, and list scroll
positions cannot be read from JS — you can drive them through the ref, and
react to the collapse through onHeaderOffsetChange, but there is no
scrollY to observe.
Does it work with react-native-screens / React Navigation? Yes — it's a regular view; render it inside any screen.
Can I pin something inside the header?
Two built-in ways: headerMinHeight keeps the header's bottom strip (a
search bar, a filter row) pinned above the tab bar, and pinTabBar={false}
lets the tabs scroll away with the header. Chrome that never moves (a nav
bar) belongs above the shell — swap its contents on onCollapsedChange.
Don't render your tabs inside renderHeader: pages clear the tab-bar band's
height either way, and an empty band gives the shell nowhere to put the tabs
back without landing on content.
Sticky section headers inside a tab (dates, months, groups)?
That's the list's job, and it works under the bands. With FlashList v2, pass
stickyHeaderIndices and stickyHeaderConfig={{ offset: contentPaddingTop }}
(from useCollapsibleTabs()), then render the StickyHeader target inside a
Reanimated view translated by -offset from onHeaderOffsetChange — the
pinned header rides up with the bands, so its pin line is always exactly the
bands' bottom edge.
Why can't I use Animated.event with useNativeDriver for the per-frame
events?
On Fabric, native-driven animated events only reach the animated module
through a deprecated back-channel that React Native special-cases for its own
ScrollView and has marked for removal. Use a Reanimated useEvent worklet
(UI thread, no per-frame JS) or a plain callback.
expand() in 'direction' mode brought the header back but the list
didn't move — bug?
Intentional. Direction mode's whole contract is that the header can be open
over content scrolled deep (what a small upward drag does mid-list), so
expand() reveals in place. Use scrollToTop() for "go to the top and
reveal". In 'classic' mode the two are the same thing, because there the
header can't open without the list being at the top.
An empty tab still collapses the header — why?
allowFullCollapse (on by default) gives a page exactly the scroll range it
lacks, so an empty state can be pushed up and the header collapses on every
tab alike. A tab that can't scroll reads as a broken screen. Pass false for
the Twitter-style alternative, where the header eases back to whatever a
short tab can hold.
A horizontal list in my header fights the header drag. It shouldn't since 0.5.2: a sideways drag belongs to the list, a vertical one to the page — decided from the gesture's translation, committed once per gesture, on both header and tab-bar bands. If you still see both moving at once, that's a bug — please report it with the list component you use.
Jest can't parse the package.
It ships untranspiled TypeScript (Metro handles it). Add it to
transformIgnorePatterns — see Install.
Which React Native versions?
≥ 0.80 with the New Architecture. The spec imports codegenNativeComponent
and CodegenTypes from the react-native root, which exists since 0.80; the
older deep imports are deprecated and warn on every launch in 0.83.
Keywords
react-native collapsible tabs · collapsing header · sticky tab bar · profile header tabs · tab view · FlashList · Fabric · new architecture · UIScrollView · ViewPager2
License
MIT © Ram Suthar
