Tabs
Displays switchable content sections using tabs with an animated indicator highlighting the active tab.
react-nativetabsnavigationanimationreanimatedui-component
Installation
Step 1: Install Dependencies
Expo Icons you can skip this if you want you use your own icons
npx expo install @expo/vector-icons/AntDesign
Step 2: Add the Typography from Components
Step 3: Copy AnimatedTabs.tsx
AnimatedTabs.tsx
import React, { useState, useRef, ReactNode } from "react";
import {
View,
Text,
TouchableOpacity,
StyleSheet,
LayoutChangeEvent,
StyleProp,
ViewStyle,
TextStyle,
} from "react-native";
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
Easing,
ReduceMotion,
} from "react-native-reanimated";
import Typography from "./Typography";
import { useAppColors } from "@/hooks/useAppColors";
export type TabItem = {
id: string;
title: string;
icon?: React.ReactElement;
content: React.ReactNode;
};
export type AnimatedTabsProps = {
tabs: TabItem[];
containerStyle?: StyleProp<ViewStyle>;
headerContainerStyle?: StyleProp<ViewStyle>;
tabStyle?: StyleProp<ViewStyle>;
tabTextStyle?: StyleProp<TextStyle>;
activeTabTextStyle?: StyleProp<TextStyle>;
indicatorStyle?: StyleProp<ViewStyle>;
reduceMotion?: "always" | "never" | "system";
};
// Animation Configuration
const INDICATOR_ANIM_DURATION = 250;
const INDICATOR_HEIGHT = 3;
const AnimatedTabs: React.FC<AnimatedTabsProps> = ({
tabs,
containerStyle,
headerContainerStyle,
tabStyle,
tabTextStyle,
activeTabTextStyle,
indicatorStyle,
reduceMotion = "system",
}) => {
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
const layoutRef = useRef<Array<{ x: number; width: number }>>([]);
const colors = useAppColors();
// Shared values for indicator position and width
const indicatorX = useSharedValue(0);
const indicatorWidth = useSharedValue(0);
const motion =
reduceMotion === "never"
? ReduceMotion.Never
: reduceMotion === "always"
? ReduceMotion.Always
: ReduceMotion.System;
const handleTabPress = (index: number) => {
if (layoutRef.current[index]) {
const { x, width } = layoutRef.current[index];
// Animate indicator position and width
indicatorX.value = withTiming(x, {
duration: INDICATOR_ANIM_DURATION,
easing: Easing.out(Easing.quad),
reduceMotion: motion,
});
indicatorWidth.value = withTiming(width, {
duration: INDICATOR_ANIM_DURATION,
easing: Easing.out(Easing.quad),
reduceMotion: motion,
});
setSelectedTabIndex(index);
}
};
const handleTabLayout = (event: LayoutChangeEvent, index: number) => {
const { x, width } = event.nativeEvent.layout;
layoutRef.current[index] = { x, width };
// Initialize indicator position on first layout of the initial tab
if (index === selectedTabIndex && indicatorWidth.value === 0) {
indicatorX.value = x;
indicatorWidth.value = width;
}
};
// Animated style for the indicator bar
const indicatorAnimatedStyle = useAnimatedStyle(() => {
return {
width: indicatorWidth.value,
transform: [{ translateX: indicatorX.value }],
};
});
// Render the current tab's content
const CurrentContent = tabs[selectedTabIndex]?.content ?? null;
return (
<View style={[styles.container, containerStyle]}>
<View style={[styles.headerContainer, headerContainerStyle, {borderBottomColor: colors.Neutral100}]}>
{tabs.map((tab, index) => (
<TouchableOpacity
key={tab.id}
style={[styles.tab, tabStyle]}
onPress={() => handleTabPress(index)}
onLayout={(event) => handleTabLayout(event, index)}
activeOpacity={0.8}
>
{tab.icon && <View style={styles.iconContainer}>{tab.icon}</View>}
<Typography
size="sm"
weight="regular"
style={[
{ color: colors.Neutral500 },
tabTextStyle,
selectedTabIndex === index && { color: colors.Neutral900 },
selectedTabIndex === index && activeTabTextStyle,
]}
numberOfLines={1}
>
{tab.title}
</Typography>
</TouchableOpacity>
))}
{/* Animated Indicator */}
<Animated.View
style={[
styles.indicator,
indicatorStyle,
indicatorAnimatedStyle,
{ backgroundColor: colors.PrimaryNormal },
]}
/>
{/* Optional static bottom border */}
<View style={[styles.headerBorder, {borderBottomColor: colors.Neutral100}]} />
</View>
{/* Content Area */}
<View style={styles.contentContainer}>{CurrentContent}</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1, // Adjust as needed, maybe height should be auto
},
headerContainer: {
flexDirection: "row",
position: "relative", // For absolute positioning of indicator and border
borderBottomWidth: StyleSheet.hairlineWidth,
// borderBottomColor: "#555",
},
tab: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
paddingVertical: 10,
paddingHorizontal: 16, // Adjust spacing between tabs
// flex: 1, // Uncomment if tabs should equally share width
},
iconContainer: {
marginRight: 6,
alignItems: 'center',
justifyContent: 'center',
},
indicator: {
position: "absolute",
bottom: 0, // Position at the bottom of the header
left: 0,
height: INDICATOR_HEIGHT,
// backgroundColor: INDICATOR_COLOR,
borderRadius: INDICATOR_HEIGHT / 2,
},
headerBorder: {
position: "absolute",
bottom: 0,
left: 0,
right: 0,
height: StyleSheet.hairlineWidth,
zIndex: -1, // Ensure it's behind the indicator
},
contentContainer: {
flex: 1,
padding: 16,
},
});
export default AnimatedTabs;
Usage
TabBarPage.tsx
import { View, Text, StyleSheet, SafeAreaView } from "react-native";
import AnimatedTabs from "@/components/ui/LineTabBar";
import AntDesign from "@expo/vector-icons/AntDesign";
import { useAppColors } from "@/hooks/useAppColors";
// Example Content Components you can import you own views here
const FeaturedContent = () => (
<View style={styles.content}>
<Text style={styles.contentText}>Featured Content Area</Text>
</View>
);
const TopGainersContent = () => (
<View style={styles.content}>
<Text style={styles.contentText}>Top Gainers Content Area</Text>
</View>
);
const TopLosersContent = () => {
return (
<View style={styles.content}>
<Text style={styles.contentText}>Top Losers Content Area</Text>
</View>
);
};
const TabBarPage = () => {
const appColors = useAppColors();
const tabData = [
{
id: "featured",
title: "Featured",
icon: <AntDesign name="staro" size={18} color={appColors.Neutral300} />,
content: <FeaturedContent />,
},
{
id: "gainers",
title: "Top Gainers",
icon: <AntDesign name="rocket1" size={18} color={appColors.Neutral300} />,
content: <TopGainersContent />,
},
{
id: "losers",
title: "Top Losers",
icon: <AntDesign name="flag" size={18} color={appColors.Neutral300} />,
content: <TopLosersContent />,
},
];
return (
<SafeAreaView style={styles.container}>
<AnimatedTabs
tabs={tabData}
reduceMotion="never"
//uncomment this too make the tab look like a moving button
// indicatorStyle={{ height: "100%", zIndex: -1, borderRadius: 16 }}
// headerContainerStyle={{ borderBottomWidth: 0 }}
/>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: "space-between",
alignItems: "center",
paddingVertical: 16,
},
content: {
// Example style for content views
flex: 1,
justifyContent: "center",
alignItems: "center",
},
contentText: {
color: "white",
fontSize: 18,
},
});
export default TabBarPage;
Props
Prop | Type | Default | Required | Description |
---|---|---|---|---|
tabs | TabItem[] | Yes | An array of tab objects, each defining its ID, title, optional icon, and content to display. | |
containerStyle | StyleProp<ViewStyle> | No | Custom styles applied to the main root container View . | |
headerContainerStyle | StyleProp<ViewStyle> | No | Custom styles applied to the View containing the tab headers and indicator. | |
tabStyle | StyleProp<ViewStyle> | No | Custom styles applied to each individual tab TouchableOpacity header. | |
tabTextStyle | StyleProp<TextStyle> | No | Custom styles applied to the text (Typography ) within each inactive tab header. | |
activeTabTextStyle | StyleProp<TextStyle> | No | Additional custom styles applied to the text (Typography ) within the active tab header. | |
indicatorStyle | StyleProp<ViewStyle> | No | Custom styles applied to the animated indicator Animated.View . | |
reduceMotion | 'always' | 'never' | 'system' | 'system' | No | Controls if/when animations are disabled ('always' , 'never' , or based on device accessibility settings). |