npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

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 ScrollView works as a tab page: ScrollView, FlatList, SectionList, FlashList, LegendList — wrapped with createTabList so 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, SwipeRefreshLayout on Android — threshold, resting offset, colours and size are props, and the native spinner can be hidden for a custom one.
  • onCollapsedChange fires 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 refscrollToTop, setIndex, collapse, expand — for "tap the active tab again" and friends.
  • A header that reacts to its own collapse (onHeaderOffsetChange as 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 Pressable does not trigger it; a deliberate tap does.
  • A minimal default TabBar (equal-width labels + underline, colours via props), or bring your own with renderTabBar.
  • 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 the ref (scrollToTop, collapse, expand, setIndex).
  • onPageScroll with RN Animated.event + useNativeDriver: true does 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 Reanimated useEvent worklet (UI thread, no per-frame JS) or accept a plain JS callback.
  • The header collapses as one band. headerMinHeight keeps its bottom strip pinned above the tab bar, and pinTabBar={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 v2 stickyHeaderIndices) and work under the bands with onHeaderOffsetChange — 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 to false for 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 UIActivityIndicatorView on iOS, SwipeRefreshLayout on 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 from onHeaderOffsetChange's pull.
  • Pages stay mounted once visited (lazy only 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 install

Autolinked 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