react-native-infinite-material-tab
v0.2.2
Published
Infinite scroll Material Design tab view for React Native — built on PagerView + Reanimated for native-grade performance. Zero JS thread work during swipe gestures.
Maintainers
Readme
react-native-infinite-material-tab
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
Architecture
┌─────────────────────────────────────────────────────┐
│ 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 │ │
│ └───────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────┘Lazy mount (lazy={true}) — pagerIndex-based (v0.2.0+)
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 visitedOnly 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.
Why This Library?
Rendering Efficiency — Only What You See
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!Infinite Loop — Clone & Jump Strategy
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 ✓Thread Architecture — Async Follow Design
┌─────────────────────────┐ ┌─────────────────────────┐
│ 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 — Smooth Swipe with Virtual Loop
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 smoothlyDynamic Tab Width
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 centeringPerformance Comparison
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)
└──────────────────────────────┘Features
- 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
Installation
npm install react-native-infinite-material-tab
# or
yarn add react-native-infinite-material-tab
# or
pnpm add react-native-infinite-material-tabPeer Dependencies
npm 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:
Usage
Basic Example
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>
);
}With Collapsible Header
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>
);
}With FlashList
<Tabs.Tab name="feed" label="Feed">
<Tabs.FlashList
data={items}
renderItem={({ item }) => <FeedCard item={item} />}
estimatedItemSize={120}
/>
</Tabs.Tab>Custom Tab Bar
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>
);
}API Reference
Tabs.Container
| 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 |
Tabs.Tab
| Prop | Type | Description |
|------|------|-------------|
| name | string | Unique tab identifier |
| label | string | Tab label text |
| children | ReactNode | Tab content |
MaterialTabBar
| 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 |
TabChangeEvent
interface TabChangeEvent {
tabName: string; // Active tab name
index: number; // Active tab index
prevTabName: string; // Previous tab name
prevIndex: number; // Previous tab index
}Hooks
| 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 |
Migration from react-native-collapsible-tab-view
- 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 installedRequirements
- Expo SDK 55+ (New Architecture only)
- React Native >= 0.83
- React >= 19.2
- react-native-reanimated >= 3.0
- react-native-pager-view >= 6.0
Contributing
Contributions are welcome! Please read our Contributing Guide for details.
License
MIT License - see the LICENSE file for details.
Author
johntips
- GitHub: @johntips
