Infinite scroll tab view for React Native — built on PagerView + Reanimated for native-grade performance.
New Architecture ready | Expo 55+ compatible | Drop-in replacement for react-native-collapsible-tab-view
┌─────────────────────────────────────────────────────┐
│ Tabs.Container │
│ │
│ ┌───────────────────────────────────────────────┐ │
│ │ Header (optional, collapsible) │ │
│ └───────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────┐ │
│ │ TabBar — ScrollView (smooth swipe) │ │
│ │ ┌─────┬─────┬─────┬─────┬─────┐ │ │
│ │ │ Tab │ Tab │[Act]│ Tab │ Tab │ ← ∞ loop │ │
│ │ └─────┴─────┴─────┴─────┴─────┘ │ │
│ │ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ← Reanimated indicator │ │
│ └───────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────┐ │
│ │ PagerView (native gestures) │ │
│ │ ┌─────────┬─────────┬─────────┐ │ │
│ │ │ Page │ [Visible│ Page │ │ │
│ │ │ (lazy) │ Page] │ (lazy) │ │ │
│ │ └─────────┴─────────┴─────────┘ │ │
│ │ offscreenPageLimit=1 → only 3 pages mounted │ │
│ └───────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────┘
With infiniteScroll=true the library generates tabs.length × BUFFER_MULTIPLIER
virtual pages so the user can swipe forever without hitting the edge. That means
multiple virtual pages share the same realIndex — pagerIndex 0, 5, 10, 15, …
all map to the same tab.
lazy={true} tracks mount state by pagerIndex, not realIndex:
realIndex: [0][1][2][3][4][0][1][2][3][4][0][1][2][3][4] ...
↑ ↑
pagerIndex 0 pagerIndex 5 (same realIndex 0)
│ │
User reaches here User swipes here
↓ ↓
renders content renders content independently
│ │
pagerIndex 5, 10, 15… pagerIndex 0, 10, 15…
stay empty until visited stay empty until visited
Only virtual pages the user actually reaches render their children. Non-visited
clones stay as empty <View> forever. This guarantees at most one HeavyContent
mount per real tab, even under heavy list rendering, complex hook composition,
or slow async data fetching inside the children.
v0.1.x had a critical bug here: mount state was tracked by realIndex, so a single tab activation triggered up to
BUFFER_MULTIPLIER(=10) parallel HeavyContent mounts, saturating the JS thread with 400–750ms dispatch latency. v0.2.0 fixes this; no API changes required.
Traditional ScrollView approach (❌ wasteful):
┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
│ 0 │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │ 8 │ 9 │10 │11 │12 │13 │14 │
└───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
▲ ▲ ▲ ▲ ▲ ▲ ▲ ▲ ▲ ▲ ▲ ▲ ▲ ▲ ▲
ALL 15 pages mounted in DOM simultaneously
Memory: O(N × VIRTUAL_MULTIPLIER) → 45 pages for 5 tabs!
This library with PagerView (✅ efficient):
┌───┬───┬───┐
│ 3 │[4]│ 5 │
└───┴───┴───┘
▲ ▲ ▲
prev cur next
Only 3 pages mounted at any time (offscreenPageLimit=1)
Memory: O(3) → constant regardless of tab count!
Page Layout (5 tabs):
┌──────────────────┬──────────────────┬──────────────────┐
│ Head Clones │ Real Pages │ Tail Clones │
│ [0] [1] [2] [3] [4]│[0] [1] [2] [3] [4]│[0] [1] [2] [3] [4]│
└──────────────────┴──────────────────┴──────────────────┘
↑ initialPage
Swipe left past clone[0]: Swipe right past clone[4]:
┌──→ idle detected ┌──→ idle detected
│ pendingJump = real[0] │ pendingJump = real[4]
│ setPageWithoutAnimation() │ setPageWithoutAnimation()
└──→ seamless! user sees no jump └──→ seamless! user sees no jump
No setTimeout ✓ No flicker ✓ Native-speed ✓
┌─────────────────────────┐ ┌─────────────────────────┐
│ UI Thread │ │ JS Thread │
│ (native, 60fps) │ │ (React, after idle) │
│ │ │ │
│ PagerView gestures │ │ onPageSelected │
│ Page transitions │ │ → setActiveIndex │
│ Reanimated indicator ◄─┼────┼──── withTiming │
│ ScrollView tab swipe │ │ Tab centering (scrollTo)│
│ │ │ onTabChange (deferred) │
└─────────────────────────┘ └─────────────────────────┘
Swipe gesture → Native thread (PagerView, 60fps, zero JS)
Tab bar scroll → Native thread (ScrollView, 60fps)
Indicator move → UI thread (withTiming, after swipe completes)
Tab centering → JS thread (scrollTo, after swipe completes)
onTabChange → JS thread (deferred to idle)
Key: swipe and tab don't wait for each other.
The initiator runs at 60fps, the follower catches up afterward.
Tab Bar (ScrollView, ×3 virtual multiplier):
┌─────────────────────────────────────────────────────────────────┐
│ Set 1 (clone) │ Set 2 (center) │ Set 3 (clone) │
│ [A][B][C][D][E] │ [A][B][C][D][E] │ [A][B][C][D][E] │
└─────────────────────────────────────────────────────────────────┘
↑ initial scroll position
User swipes tab bar freely ← →
Edge detected? → requestAnimationFrame → reset to center
No setTimeout ✓ No jank ✓ Smooth momentum ✓
Tab indicator animation:
┌─────┬─────┬─────┬─────┬─────┐
│ A │ B │ [C] │ D │ E │ activeIndex: 2
└─────┴─────┴─────┴─────┴─────┘
▓▓▓▓▓ ← Animated.View
useSharedValue(x, width)
Tab press C → D: withTiming(200ms)
┌─────┬─────┬─────┬─────┬─────┐
│ A │ B │ C │ [D] │ E │
└─────┴─────┴─────┴─────┴─────┘
▓▓▓▓▓ ← slides smoothly
Fixed width (❌ old):
┌──────────┬──────────┬──────────┬──────────┬──────────┐
│ Tech │ Business │ AI │ Sports │ Music │
│ 100px │ 100px │ 100px │ 100px │ 100px │
└──────────┴──────────┴──────────┴──────────┴──────────┘
Wastes space on short labels, truncates long ones
Dynamic width (✅ new):
┌──────┬──────────┬─────┬────────┬───────┐
│ Tech │ Business │ AI │ Sports │ Music │
│ 56px │ 88px │40px │ 72px │ 64px │
└──────┴──────────┴─────┴────────┴───────┘
Each tab measured via onLayout → pixel-perfect centering
This Library ScrollView-based
──────────── ────────────────
Page engine PagerView (native) ScrollView (JS)
Gesture tracking UI thread JS thread
Mounted pages 3 (constant) N × multiplier
Tab indicator Reanimated worklet Conditional render
Edge reset rAF + idle event setTimeout(100ms)
Jump mechanism setPageWithoutAnim scrollTo + setTimeout
Tab item re-render React.memo Full re-render
Tab width Dynamic (onLayout) Fixed (100px)
┌──────────────────────────────┐
Frame budget (16ms): │ │
│ ████░░░░░░░░░░░░ 8ms ✅ │ This library
│ ████████████████ 16ms ⚠️ │ ScrollView-based
│ ████████████████████ 22ms ❌│ (frame drop)
└──────────────────────────────┘
- PagerView — native page gestures, 60fps guaranteed
- Infinite horizontal scroll for tabs and content
- Reanimated indicator — smooth sliding animation on UI thread
- Dynamic tab width — auto-measured via
onLayout - Lazy rendering —
lazy={true}+offscreenPageLimit={1}; only the virtual pages the user actually reaches render their children (see Lazy mount section below) - Zero setTimeout — all timing via
requestAnimationFrame+ idle detection - Active tab center alignment — auto-scrolls with shortest-path algorithm
- Collapsible header support
- New Architecture (Fabric) ready
- Expo 55+ compatible
- Drop-in replacement for react-native-collapsible-tab-view
- FlashList compatible
- TypeScript first
npm install react-native-infinite-material-tab
# or
yarn add react-native-infinite-material-tab
# or
pnpm add react-native-infinite-material-tabnpm install react-native-reanimated react-native-pager-view| Package | Required | Purpose |
|---|---|---|
react-native-reanimated |
Yes | Tab indicator animation (UI thread) |
react-native-pager-view |
Yes | Native page gestures & transitions |
@shopify/flash-list |
Optional | High-performance list in tab content |
Follow the setup guides:
import { Tabs } from 'react-native-infinite-material-tab';
function App() {
return (
<Tabs.Container
infiniteScroll={true}
tabBarCenterActive={true}
onTabChange={(event) => console.log(event.tabName)}
>
<Tabs.Tab name="tech" label="Tech">
<Tabs.FlatList
data={newsItems}
renderItem={({ item }) => <NewsCard item={item} />}
/>
</Tabs.Tab>
<Tabs.Tab name="business" label="Business">
<Tabs.FlatList
data={businessItems}
renderItem={({ item }) => <NewsCard item={item} />}
/>
</Tabs.Tab>
{/* ... more tabs */}
</Tabs.Container>
);
}const HEADER_HEIGHT = 200;
function App() {
return (
<Tabs.Container
renderHeader={() => (
<View style={{ height: HEADER_HEIGHT }}>
<Image source={require('./banner.png')} />
</View>
)}
headerHeight={HEADER_HEIGHT}
>
<Tabs.Tab name="home" label="Home">
<Tabs.ScrollView>
<YourContent />
</Tabs.ScrollView>
</Tabs.Tab>
</Tabs.Container>
);
}<Tabs.Tab name="feed" label="Feed">
<Tabs.FlashList
data={items}
renderItem={({ item }) => <FeedCard item={item} />}
estimatedItemSize={120}
/>
</Tabs.Tab>import { Tabs, MaterialTabBar } from 'react-native-infinite-material-tab';
// Use built-in MaterialTabBar with customization
<Tabs.Container
renderTabBar={(props) => (
<MaterialTabBar
{...props}
activeColor="#F3BE21"
inactiveColor="#86888A"
indicatorStyle={{ height: 2 }}
/>
)}
>
{/* tabs */}
</Tabs.Container>
// Or build your own
function CustomTabBar({ tabs, activeIndex, onTabPress }: TabBarProps) {
return (
<View style={{ flexDirection: 'row' }}>
{tabs.map((tab, index) => (
<TouchableOpacity
key={tab.name}
onPress={() => onTabPress(index)}
>
<Text style={{ color: activeIndex === index ? 'blue' : 'gray' }}>
{tab.label}
</Text>
</TouchableOpacity>
))}
</View>
);
}| Prop | Type | Default | Description |
|---|---|---|---|
children |
ReactNode |
- | Tabs.Tab components |
renderHeader |
() => ReactElement |
- | Header above tabs |
renderTabBar |
(props: TabBarProps) => ReactElement |
- | Custom tab bar |
headerHeight |
number |
0 |
Header height (px) |
infiniteScroll |
boolean |
true |
Enable infinite loop |
tabBarCenterActive |
boolean |
true |
Auto-center active tab |
onTabChange |
(event: TabChangeEvent) => void |
- | Tab change callback |
onFocusedTabPress |
(index: number) => void |
- | Called when the already-active tab is pressed again (e.g. scroll to top) |
initialTabName |
string |
- | Initial active tab name |
pagerProps |
Partial<PagerViewProps> |
- | Props forwarded to PagerView |
containerStyle |
StyleProp<ViewStyle> |
- | Container style |
headerContainerStyle |
StyleProp<ViewStyle> |
- | Header wrapper style |
tabBarContainerStyle |
StyleProp<ViewStyle> |
- | Tab bar wrapper style |
offscreenPageLimit |
number |
1 |
PagerView offscreen pages (1=3 pages, 2=5 pages) |
lazy |
boolean |
false |
Only mount tab content when nearby (reduces JS thread load for heavy tabs) |
debug |
boolean |
false |
Enable debug logging (nearby/active/unmounted transitions) |
onDebugLog |
(event: DebugLogEvent) => void |
- | Debug log callback for app-side logging |
| Prop | Type | Description |
|---|---|---|
name |
string |
Unique tab identifier |
label |
string |
Tab label text |
children |
ReactNode |
Tab content |
| Prop | Type | Default | Description |
|---|---|---|---|
activeColor |
string |
"#000" |
Active tab text & indicator color |
inactiveColor |
string |
"#666" |
Inactive tab text color |
scrollEnabled |
boolean |
true |
Enable horizontal scroll |
indicatorStyle |
StyleProp<ViewStyle> |
- | Indicator style override |
labelStyle |
StyleProp<TextStyle> |
- | Label style override |
tabStyle |
StyleProp<ViewStyle> |
- | Tab item style override |
interface TabChangeEvent {
tabName: string; // Active tab name
index: number; // Active tab index
prevTabName: string; // Previous tab name
prevIndex: number; // Previous tab index
}| Hook | Returns | Description |
|---|---|---|
useCurrentTabScrollY() |
SharedValue<number> |
Current tab's scroll Y position |
useActiveTabIndex() |
number |
Currently active tab index |
useTabs() |
Tab[] |
Array of tab info |
useIsNearby(tabName) |
boolean |
Whether the tab is active or adjacent (for prefetching) |
useNearbyIndexes() |
number[] |
Array of active + adjacent tab indexes |
useTabsContext() |
TabsContextValue |
Full context value |
- import { Tabs } from 'react-native-collapsible-tab-view';
+ import { Tabs } from 'react-native-infinite-material-tab';Add peer dependency:
npm install react-native-pager-view # if not already installed- Expo SDK 55+ (New Architecture only)
- React Native >= 0.83
- React >= 19.2
- react-native-reanimated >= 3.0
- react-native-pager-view >= 6.0
Contributions are welcome! Please read our Contributing Guide for details.
MIT License - see the LICENSE file for details.
johntips
- GitHub: @johntips

