DEV Community

Jade Zhou
Jade Zhou

Posted on

Introducing StickyTabView: Connecting Collapsible Headers, Sticky Tabs, and Scrolling in React Native

Hello, everyone! I'm @jadezhou,an independent developer.

And I recently decided to clean up and open-source a React Native component I had built in my previous work.

If you have built a profile screen, a content feed with tabs, or a product detail page, you have probably worked with this layout: a header at the top, a tab bar that sticks as you scroll, horizontal swiping between tabs, and independent vertical scrolling within each tab.

It looks like a combination of familiar components. In practice, React Native's gesture handling and event propagation make it more complicated than it first appears.

To address these requirements in my own projects, I built and open-sourced @jadezhou/sticky-tab-view. It provides a collapsible header, a sticky tab bar, horizontal paging, and synchronized scrolling across tabs, along with pull-to-refresh and masonry list components.

First, here is what it looks like:

There are already some excellent solutions in the React Native ecosystem. Here are the advantages I wanted to offer with @jadezhou/sticky-tab-view:

  • Scrolling can start in the header. Unlike many existing solutions that only respond to drags within the list area, the header can also handle drag gestures. This keeps scrolling accessible even when the header takes up most of the screen.
  • You can customize where the tab bar sticks. The area retained above it can contain custom content and effectsβ€”for example, a header section that remains pinned above the tab bar.
  • Support for the New Architecture and react-native-worklets for better performance.
  • A ready-to-use MasonryList component for long masonry lists.

The demo covers refreshing and loading more items in a regular list, a two-column masonry layout, vertical paging, and a scrollable page with content insets and scroll indicators.

The current npm latest version is 2.0.0. This article focuses on the 2.x main line, built on Reanimated 4, and pins the installation example to that version. Existing Reanimated 3 projects can use the separate 1.x maintenance line; I explain the distinction later in the article.

What makes this difficult?

Collapsing a header as the user scrolls is only one part of the interaction. The harder part is coordinating an overlaid header, a horizontal pager, and multiple vertically scrollable pages.

For example, when someone drags upward on the header, they expect the active tab's content to move upward and the header to collapse with it. But the header and the tab content may live in separate parts of the component tree. Something has to decide which component receives the gesture and which one updates the scroll offset.

Switching tabs introduces another problem. Suppose the first tab has been scrolled far enough to fully collapse the header, while the second tab is still at the top. Restoring the second tab's original offset would make the header suddenly expand.

My application also needed to retain part of the header above the tab bar once the bar became sticky. The library exposes headerOffset to control that retained height, which you can combine with custom content to create this effect. It is not a general-purpose system for arbitrarily nested sticky sections.

After trying existing approaches, I decided to build a shared scroll-state model and gesture arbitration logic around these specific interactions.

Four components, each with a distinct role

Component Purpose
StickyTabView Combines the header, tab bar, and horizontal pager; coordinates vertical offsets across tabs; supports lazy mounting and preloading adjacent tabs
ElasticScrollView A gesture-driven scroll container with bounce, momentum, paging, refresh, and end-reached callbacks
ElasticPullRefreshHeader The default pull-to-refresh indicator, replaceable through the component interface
MasonryList A masonry layout for items with known heights, with pagination, sections, error retries, and cell reuse

Drags that start in the header can be forwarded to the connected scroll container in the active tab, allowing both the header and content area to participate in scrolling. If your custom header contains buttons or other gestures, you should also test how those interactions work together in your screen.

Two constraints are worth understanding before choosing the library:

  • ElasticScrollView moves its content using transform. It is not a native ScrollView and does not automatically virtualize its children. It is intended for a bounded amount of content.
  • MasonryList has its own layout and cell-reuse mechanism. You must synchronously provide each item's actual height through heightForItem. Image and text heights need to be known in advance; you cannot use it exactly like a list that measures item heights dynamically.

An existing FlatList or FlashList does not automatically participate in this synchronization protocol simply because you place it inside renderTab. The example below uses the library's ElasticScrollView.

Getting started

This setup targets Expo SDK 54 / React Native 0.81 / Reanimated 4.1 / Worklets 0.5. Check that your application meets the version requirements listed later in this article, then install:

npm install @jadezhou/sticky-tab-view@2.0.0
npx expo install react-native-gesture-handler react-native-reanimated react-native-worklets
Enter fullscreen mode Exit fullscreen mode

Expo projects use the configuration provided by babel-preset-expo. React Native Community CLI projects need to add react-native-worklets/plugin as the last Babel plugin. See the project README for the configuration details. Rebuild the native application after installing or upgrading native dependencies. After changing the Babel configuration, clear Metro's cache and restart it.

Here is a two-tab example that supports both tapping tabs and swiping between pages. It includes GestureHandlerRootView at the root. If your application already has that wrapper at its entry point, keep the existing one instead of adding another here.

import React, { useCallback, useRef } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import {
  ElasticScrollView,
  StickyTabView,
} from '@jadezhou/sticky-tab-view';
import type { StickyTabViewHandle } from '@jadezhou/sticky-tab-view';

const HEADER_HEIGHT = 200;
const TAB_BAR_HEIGHT = 48;

export default function ProfileScreen() {
  const tabsRef = useRef<StickyTabViewHandle>(null);

  const renderHeader = useCallback(
    () => (
      <View style={styles.header}>
        <Text style={styles.title}>My Profile</Text>
        <Text>You can start scrolling from this header, too.</Text>
      </View>
    ),
    [],
  );

  const renderTabBar = useCallback(
    () => (
      <View style={styles.tabBar}>
        {['Posts', 'Activity'].map((label, index) => (
          <Pressable
            key={label}
            accessibilityRole="button"
            onPress={() => tabsRef.current?.setTab(index)}
            style={styles.tabButton}
          >
            <Text>{label}</Text>
          </Pressable>
        ))}
      </View>
    ),
    [],
  );

  const renderTab = useCallback(
    (tab: number) => (
      <ElasticScrollView>
        {/* Reserve space for the overlaid header and tab bar */}
        <View style={styles.headerPlaceholder} />

        {Array.from({ length: 20 }, (_, index) => (
          <View key={index} style={styles.item}>
            <Text>
              Tab {tab + 1} Β· Item {index + 1}
            </Text>
          </View>
        ))}
      </ElasticScrollView>
    ),
    [],
  );

  return (
    <GestureHandlerRootView style={styles.root}>
      <StickyTabView
        ref={tabsRef}
        tabCount={2}
        tabBarHeight={TAB_BAR_HEIGHT}
        renderHeader={renderHeader}
        renderTabBar={renderTabBar}
        renderTab={renderTab}
      />
    </GestureHandlerRootView>
  );
}

const styles = StyleSheet.create({
  root: { flex: 1, backgroundColor: '#f3f4f6' },
  tabButton: { paddingHorizontal: 12, paddingVertical: 12 },
  header: {
    height: HEADER_HEIGHT,
    padding: 24,
    justifyContent: 'flex-end',
    backgroundColor: '#dfe6ff',
  },
  title: {
    marginBottom: 8,
    fontSize: 28,
    fontWeight: '700',
  },
  tabBar: {
    height: TAB_BAR_HEIGHT,
    paddingHorizontal: 24,
    flexDirection: 'row',
    alignItems: 'center',
    gap: 32,
    backgroundColor: 'white',
  },
  headerPlaceholder: {
    height: HEADER_HEIGHT + TAB_BAR_HEIGHT,
  },
  item: {
    marginHorizontal: 16,
    marginBottom: 12,
    padding: 20,
    borderRadius: 12,
    backgroundColor: 'white',
  },
});
Enter fullscreen mode Exit fullscreen mode

There are three easy-to-miss details in this example:

  1. Reserve space for the header and tab bar at the top of every page. They overlay the content, and the library does not add a spacer automatically. If your layout includes safe-area insets, account for them in the actual rendered height.
  2. tabBarHeight must match the rendered tab bar's height. To switch tabs after mounting, call ref.current?.setTab(index). The current prop only sets the initial tab; it is not a controlled value.
  3. This example switches tabs but does not draw an active-tab indicator. The x, ys, and currentPage arguments passed to renderTabBar are SharedValues. You can use them in animated styles to drive an indicator. Reading .value directly during a React render does not subscribe the component to changes.

For screens with more tabs, enable lazy and set lazyPreloadDistance={1} to mount pages as they are visited and preload neighboring pages. Visited pages remain mounted. This is separate from virtualizing a long list within a page.

How does scrolling stay continuous?

The key is to have the header and the active page read the same vertical scroll offset.

StickyTabView maintains a few important pieces of state:

  • x: the horizontal paging offset.
  • ys[]: the vertical offset for each tab.
  • currentPage: the active page.
  • focus: the direction and ownership of the current gesture or animation.

These frequently updated values are primarily stored in Reanimated SharedValues.

During a gesture, worklets determine the direction, handle boundaries, and update offsets on the UI thread. Refreshing, loading more data, and state notifications are scheduled on the JavaScript thread. If you provide onScroll, offset changes also notify JavaScript. There is currently no built-in throttling for that callback, so avoid expensive work inside it.

The header's position is derived from the active page's vertical offset:

maximum collapse distance = max(
  0,
  measured overlay height (header + tab bar) - tab bar height - headerOffset
)
Enter fullscreen mode Exit fullscreen mode

When switching tabs, the component also synchronizes the header's collapse state across pages:

  • If the header is not fully collapsed, the other tabs adopt the active tab's offset.
  • If the header is fully collapsed, the other tabs are moved to at least the collapse threshold.
  • Tabs that have already scrolled beyond that threshold keep their deeper positions.

This keeps the header's position consistent across tab changes while preserving each page's scroll progress beyond the collapse threshold. While the header is still expanded, offsets near the top are synchronized rather than always preserved exactly as they were.

This design places gesture arbitration, offset updates, and the main animations on the UI thread. The core scrolling movement does not depend on updating React state every frame. The trade-off is that the library has to manage compatibility between its scrolling behavior, cell reuse, and native interactions.

The repository includes performance test scenarios with different dataset sizes. Real-device FPS, memory usage, and high-refresh-rate behavior still need ongoing measurement. There is not yet enough device baseline data to claim a guaranteed frame rate or a performance advantage over other solutions.

Supported versions and platforms

Version 2.0.0, used in this article, targets the following ranges. Peer dependency ranges define installation constraints; they do not mean that every possible combination has received the same level of device testing.

Dependency or platform Supported range
React >=19.1.0 <20.0.0
React Native 0.81.x
React Native Gesture Handler 2.28.x
React Native Reanimated 4.1.x
React Native Worklets 0.5.x
React Native architecture New Architecture (Fabric) only
Platforms iOS and Android
Web Experimental build compatibility

This version is ESM-only; it does not include a CommonJS build.

For existing Reanimated 3 projects, see the 1.x maintenance-line documentation. That line does not depend on the separate react-native-worklets package. Its supported versions and Babel configuration differ from 2.x, so explicitly select a maintenance-line version when installing and do not mix the two configurations.

Custom scrolling also does not automatically inherit all of the native ScrollView's keyboard behavior, scrolling focused elements into view, or accessibility behavior. The library has some foundational support and tests in these areas, but you should still validate your application's interactions on your target devices.

When is it a good fit?

If you are building a profile screen, product detail page, or content feed that needs a collapsible header, horizontal paging, and synchronized vertical scrolling, this library is worth trying with your own content.

For a simple fixed tab bar without a collapsible header, cross-tab synchronization, or complex gesture requirements, a regular pager and a virtualized list may be a lighter option.

My goal is to solve a specific class of interactions that takes substantial effort to implement, rather than replace every scrolling and list component in React Native.

Try it out and share your feedback

This project grew out of requirements in my own application and is open source under the MIT license. If you are working on header gestures, sticky tabs, or synchronized scrolling across pages, start with the demo and then test it with your own content and interactions.

I am especially interested in feedback on nested gestures, switching tabs after refreshing, content with varying heights, and long lists. When opening an issue, please include your platform, React Native and Reanimated versions, and a minimal reproduction or screen recording to help with debugging.

One last thing: I also built a notes app called Brim, available on both macOS and iOS, with iCloud sync. Its goal is to bring sticky notes back to their essence, using a distinctive interaction to make capturing notes feel lighter. If you use a Mac or iPhone, feel free to download it and give it a try β€” new users get a 7-day free trial.

Top comments (0)