Tabs

An animated top tabs layout

Last updated on

Edit on GitHub

Manual

Install the following dependencies:

npm install react-native-reanimated @sbaiahmed1/react-native-blur

Copy and paste the following code into your project. component/base/tabs.tsx

import React, { useState, useRef, useCallback, useEffect } from "react";import {  View,  Text,  FlatList,  TouchableOpacity,  StyleSheet,  useWindowDimensions,  type LayoutChangeEvent,  type NativeSyntheticEvent,  type NativeScrollEvent,  type ScaledSize,  ViewStyle,  Platform,} from "react-native";import type {  TopTabsProps,  Tab,  ContentItemProps,  AnimatedTabItemProps,  TabItemProps,} from "./types";import Animated, {  useAnimatedStyle,  useSharedValue,  useAnimatedScrollHandler,  interpolate,  Extrapolation,  useAnimatedProps,  interpolateColor,} from "react-native-reanimated";import { BlurView } from "@sbaiahmed1/react-native-blur";const AnimatedBlurView = Animated.createAnimatedComponent(BlurView);const AnimatedFlatList = Animated.createAnimatedComponent(  FlatList as new () => FlatList<Tab>,);const TAB_PADDING: number = 20;const MIN_UNDERLINE_WIDTH: number = 0.5;const ContentItem: React.FC<ContentItemProps> = ({  item,  index,  scrollX,  screenWidth,}) => {  const animatedBlurViewProps = useAnimatedProps(() => {    "worklet";    const currentScreenPosition = index * screenWidth;    const prevScreenPosition = (index - 1) * screenWidth;    const nextScreenPosition = (index + 1) * screenWidth;    const blurAmount = interpolate(      scrollX.value,      [prevScreenPosition, currentScreenPosition, nextScreenPosition],      [50, 0, 50],      Extrapolation.CLAMP,    );    return {      blurAmount: Math.max(0, Math.min(100, blurAmount)),    };  });  const animatedViewStylez = useAnimatedStyle(() => {    "worklet";    const currentScreenPosition = index * screenWidth;    const prevScreenPosition = (index - 1) * screenWidth;    const nextScreenPosition = (index + 1) * screenWidth;    const opacity = interpolate(      scrollX.value,      [prevScreenPosition, currentScreenPosition, nextScreenPosition],      [0, 1, 0],      Extrapolation.CLAMP,    );    return {      opacity,    };  });  return (    <Animated.View      style={[        styles.contentWrapper,        { width: screenWidth },        animatedViewStylez,        {          padding: 20,        },      ]}    >      {item.contentComponent ? (        item.contentComponent      ) : (        <Text style={styles.contentText}>{item.content}</Text>      )}      <View style={StyleSheet.absoluteFill} pointerEvents="none">        {Platform.OS === "ios" && (          <AnimatedBlurView            animatedProps={animatedBlurViewProps}            blurType="regular"            style={StyleSheet.absoluteFill}          />        )}      </View>    </Animated.View>  );};const TabItem: React.FC<AnimatedTabItemProps> = ({  tab,  index,  scrollX,  screenWidth,  onPress,  onLayout,  activeColor = "#007AFF",  inactiveColor = "#666",}) => {  const animatedTextStylez = useAnimatedStyle(() => {    "worklet";    const currentScreenPosition = index * screenWidth;    const prevScreenPosition = (index - 1) * screenWidth;    const nextScreenPosition = (index + 1) * screenWidth;    // Calculate how "active" this tab is (0 = inactive, 1 = fully active)    const activeProgress = interpolate(      scrollX.value,      [prevScreenPosition, currentScreenPosition, nextScreenPosition],      [0, 1, 0],      Extrapolation.CLAMP,    );    return {      color: interpolateColor(        activeProgress,        [0, 1],        [inactiveColor, activeColor],      ),    };  });  // Determine if active for non-animated parts (like custom titleComponent)  const isActive = Math.round(scrollX.value / screenWidth) === index;  return (    <TouchableOpacity      style={styles.tabItem}      onPress={onPress}      onLayout={onLayout}      activeOpacity={0.7}    >      {tab.titleComponent ? (        typeof tab.titleComponent === "function" ? (          tab.titleComponent(isActive, activeColor, inactiveColor)        ) : (          tab.titleComponent        )      ) : (        <Animated.Text style={[styles.tabText, animatedTextStylez]}>          {tab.title}        </Animated.Text>      )}    </TouchableOpacity>  );};export const TopTabs: React.FC<TopTabsProps> = ({  tabs,  activeColor = "#007AFF",  inactiveColor = "#666",  underlineColor = "#007AFF",  underlineHeight = 3,}) => {  const { width: screenWidth }: ScaledSize = useWindowDimensions();  const [activeIndex, setActiveIndex] = useState<number>(0);  const [isLayoutReady, setIsLayoutReady] = useState<boolean>(false);  const tabWidths = useRef<number[]>(tabs.map(() => 0));  const tabPositions = useRef<number[]>(tabs.map(() => 0));  const measuredCount = useRef<number>(0);  const contentFlatListRef = useRef<FlatList<Tab>>(null);  const tabBarFlatListRef = useRef<FlatList<Tab>>(null);  const isTabPress = useRef<boolean>(false);  const scrollX = useSharedValue<number>(0);  const tabBarScrollX = useSharedValue<number>(0);  const sharedTabWidths = useSharedValue<number[]>(tabs.map(() => 0));  const sharedTabPositions = useSharedValue<number[]>(tabs.map(() => 0));  const calculatePositions = () => {    let position = 0;    tabPositions.current = tabWidths.current.map((width) => {      const currentPosition = position;      position += width;      return currentPosition;    });  };  const scrollTabBarToIndex = useCallback(    (index: number) => {      if (!isLayoutReady) return;      try {        tabBarFlatListRef.current?.scrollToIndex({          index,          animated: true,          viewPosition: 0.2,        });      } catch (error) {        const offset = tabPositions.current[index] || 0;        tabBarFlatListRef.current?.scrollToOffset({          offset,          animated: true,        });      }    },    [isLayoutReady],  );  useEffect(() => {    scrollTabBarToIndex(activeIndex);  }, [activeIndex, scrollTabBarToIndex]);  const handleTabLayout = <T extends LayoutChangeEvent, I extends number>(    event: T,    index: I,  ) => {    const { width } = event.nativeEvent.layout;    if (tabWidths.current[index] === 0) {      measuredCount.current += 1;    }    tabWidths.current[index] = width;    calculatePositions();    sharedTabWidths.value = [...tabWidths.current];    sharedTabPositions.value = [...tabPositions.current];    if (measuredCount.current === tabs.length && !isLayoutReady) {      setIsLayoutReady(true);    }  };  const handleTabPress: <I extends number>(number: I) => void = <    I extends number,  >(    index: I,  ) => {    if (index === activeIndex) return;    isTabPress.current = true;    try {      contentFlatListRef.current?.scrollToIndex({        index,        animated: true,      });      setActiveIndex(index);    } catch (error) {      contentFlatListRef.current?.scrollToOffset({        offset: index * screenWidth,        animated: true,      });    }    setTimeout(() => {      isTabPress.current = false;    }, 100);  };  const contentScrollHandler = useAnimatedScrollHandler<    Record<string, unknown>  >({    onScroll: (event) => {      scrollX.value = event.contentOffset.x;    },  });  const tabBarScrollHandler = useAnimatedScrollHandler<Record<string, unknown>>(    {      onScroll: (event) => {        tabBarScrollX.value = event.contentOffset.x;      },    },  );  const underlineAnimatedStyle = useAnimatedStyle<ViewStyle>(() => {    "worklet";    const inputRange = tabs.map((_, index) => index * screenWidth);    const positions = sharedTabPositions.value;    const widths = sharedTabWidths.value;    if (widths.length === 0 || widths[0] === 0 || positions.length === 0) {      return { left: 0, width: 0, opacity: 0 };    }    const absoluteLeft = interpolate(      scrollX.value,      inputRange,      positions,      Extrapolation.CLAMP,    );    const tabWidth = interpolate(      scrollX.value,      inputRange,      widths,      Extrapolation.CLAMP,    );    const tabLeft = absoluteLeft - tabBarScrollX.value;    const tabCenterX = tabLeft + tabWidth / 2;    const shrinkAmount = 20;    const underlineWidth = Math.max(      tabWidth - shrinkAmount,      MIN_UNDERLINE_WIDTH,    );    const underlineLeft = tabCenterX - underlineWidth / 2;    return {      left: underlineLeft,      width: underlineWidth,      opacity: 1,    };  });  const onMomentumScrollEnd = (    event: NativeSyntheticEvent<NativeScrollEvent>,  ) => {    if (!isTabPress.current) {      const newIndex = Math.round(        event.nativeEvent.contentOffset.x / screenWidth,      );      setActiveIndex(newIndex);    }  };  const renderTabItem = ({ item, index }: { item: Tab; index: number }) => (    <TabItem      tab={item}      index={index}      scrollX={scrollX}      screenWidth={screenWidth}      onPress={() => handleTabPress(index)}      onLayout={(event) => handleTabLayout(event, index)}      activeColor={activeColor}      inactiveColor={inactiveColor}    />  );  const renderContentItem = ({ item, index }: { item: Tab; index: number }) => {    return (      <ContentItem        item={item}        index={index}        scrollX={scrollX}        screenWidth={screenWidth}      />    );  };  const getItemLayout = (_: any, index: number) => {    const offset = tabWidths.current      .slice(0, index)      .reduce((sum, w) => sum + w, 0);    return {      length: tabWidths.current[index] || 0,      offset,      index,    };  };  return (    <View style={styles.container}>      <View style={styles.tabBarContainer}>        <AnimatedFlatList          ref={tabBarFlatListRef}          data={tabs}          renderItem={renderTabItem}          keyExtractor={(item) => item.id}          horizontal          showsHorizontalScrollIndicator={false}          onScroll={tabBarScrollHandler}          scrollEventThrottle={16}          getItemLayout={getItemLayout}        />        <Animated.View          style={[            styles.underline,            { backgroundColor: underlineColor, height: underlineHeight },            underlineAnimatedStyle,          ]}        />      </View>      <AnimatedFlatList        ref={contentFlatListRef}        data={tabs}        renderItem={renderContentItem}        keyExtractor={(item) => `content-${item.id}`}        horizontal        pagingEnabled        showsHorizontalScrollIndicator={false}        bounces={false}        onScroll={contentScrollHandler}        scrollEventThrottle={16}        onMomentumScrollEnd={onMomentumScrollEnd}        style={styles.contentFlatList}        getItemLayout={(_, index) => ({          length: screenWidth,          offset: screenWidth * index,          index,        })}      />    </View>  );};const styles = StyleSheet.create({  container: {    flex: 1,  },  tabBarContainer: {},  tabItem: {    paddingHorizontal: TAB_PADDING,    paddingVertical: 16,  },  tabText: {    fontSize: 16,  },  activeTabText: {    fontWeight: "600",  },  underline: {    position: "absolute",    bottom: 0,    left: 0,    borderRadius: 1.5,  },  contentFlatList: {    flex: 1,  },  contentWrapper: {    flex: 1,    position: "relative",  },  contentContainer: {    flex: 1,    padding: 20,    justifyContent: "center",    alignItems: "center",  },  contentText: {    fontSize: 18,    color: "#333",  },});

Usage

import { View, Text, StyleSheet } from "react-native";import { GestureHandlerRootView } from "react-native-gesture-handler";import { StatusBar } from "expo-status-bar";import { useFonts } from "expo-font";import { SymbolView } from "expo-symbols";import { TopTabs } from "@/components/base/tabs";import Animated, {  useAnimatedStyle,  useSharedValue,  withTiming,  interpolate,  interpolateColor,} from "react-native-reanimated";import { useEffect as useReactEffect } from "react";const TabTitle = ({  icon,  label,  isActive,  fontLoaded,  showTitle = true,}: {  icon: string;  label: string;  isActive: boolean;  fontLoaded: boolean;  showTitle?: boolean;}) => {  const progress = useSharedValue(isActive ? 1 : 0);  useReactEffect(() => {    progress.value = withTiming(isActive ? 1 : 0, { duration: 250 });  }, [isActive]);  const containerStyle = useAnimatedStyle(() => ({    opacity: interpolate(progress.value, [0, 1], [0.4, 1]),    transform: [      { scale: interpolate(progress.value, [0, 1], [0.92, 1]) },      { translateY: interpolate(progress.value, [0, 1], [2, 0]) },    ],  }));  const textStyle = useAnimatedStyle(() => ({    color: interpolateColor(progress.value, [0, 1], ["#8b8b8b", "#fff"]),  }));  return (    <Animated.View style={[styles.tabTitle, containerStyle]}>      <SymbolView        name={icon as any}        size={15}        tintColor={isActive ? "#fff" : "#8b8b8b"}      />      {showTitle && (        <Animated.Text          style={[            styles.tabLabel,            textStyle,            fontLoaded && { fontFamily: "SfProRounded" },          ]}        >          {label}        </Animated.Text>      )}    </Animated.View>  );};export default function App() {  const [fontLoaded] = useFonts({    SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"),    HelveticaNowDisplay: require("@/assets/fonts/HelveticaNowDisplayMedium.ttf"),  });  const TABS = [    {      id: "1",      title: "For You",      titleComponent: (isActive: boolean) => (        <TabTitle          icon="sparkles"          label="For You"          isActive={isActive}          fontLoaded={fontLoaded}        />      ),      contentComponent: (        <View style={styles.tabContent}>          {["Discover", "Saved", "History"].map((item, i) => (            <View key={i} style={styles.item}>              <SymbolView                name={["sparkles", "heart.fill", "clock.fill"][i] as any}                size={18}                tintColor="#a78bfa"              />              <Text                style={[                  styles.itemText,                  fontLoaded && { fontFamily: "SfProRounded" },                ]}              >                {item}              </Text>            </View>          ))}        </View>      ),    },    {      id: "2",      title: "Trending",      titleComponent: (isActive: boolean) => (        <TabTitle          icon="chart.line.uptrend.xyaxis"          label="Trending"          isActive={isActive}          fontLoaded={fontLoaded}        />      ),      contentComponent: (        <View style={styles.tabContent}>          {["Design", "Code", "Music"].map((item, i) => (            <View key={i} style={styles.item}>              <Text                style={[                  styles.rank,                  fontLoaded && { fontFamily: "HelveticaNowDisplay" },                ]}              >                {i + 1}              </Text>              <Text                style={[                  styles.itemText,                  fontLoaded && { fontFamily: "SfProRounded" },                ]}              >                {item}              </Text>              <SymbolView name="arrow.up.right" size={14} tintColor="#34d399" />            </View>          ))}        </View>      ),    },    {      id: "3",      title: "New",      titleComponent: (isActive: boolean) => (        <TabTitle          label=""          icon="clock.fill"          showTitle={false}          isActive={isActive}          fontLoaded={fontLoaded}        />      ),      contentComponent: (        <View style={styles.tabContent}>          {["Today", "This Week", "This Month"].map((item, i) => (            <View key={i} style={styles.item}>              <View style={[styles.dot, { opacity: 1 - i * 0.3 }]} />              <Text                style={[                  styles.itemText,                  fontLoaded && { fontFamily: "SfProRounded" },                ]}              >                {item}              </Text>            </View>          ))}        </View>      ),    },  ];  return (    <GestureHandlerRootView style={styles.container}>      <StatusBar style="light" />      <Text        style={[          styles.header,          fontLoaded && { fontFamily: "HelveticaNowDisplay" },        ]}      >        Explore      </Text>      <TopTabs        tabs={TABS}        activeColor="#fff"        inactiveColor="#555"        underlineColor="#fff"        underlineHeight={2}      />    </GestureHandlerRootView>  );}const styles = StyleSheet.create({  container: {    flex: 1,    backgroundColor: "#0a0a0a",  },  header: {    fontSize: 32,    fontWeight: "700",    color: "#fff",    paddingHorizontal: 20,    paddingTop: 60,    paddingBottom: 16,  },  tabTitle: {    flexDirection: "row",    alignItems: "center",    gap: 6,  },  tabLabel: {    fontSize: 15,    fontWeight: "600",  },  tabContent: {    flex: 1,    paddingTop: 8,    gap: 8,  },  item: {    flexDirection: "row",    alignItems: "center",    backgroundColor: "#111",    padding: 16,    borderRadius: 14,    gap: 12,  },  itemText: {    flex: 1,    fontSize: 16,    color: "#fff",  },  rank: {    fontSize: 18,    fontWeight: "700",    color: "#333",    width: 24,  },  dot: {    width: 8,    height: 8,    borderRadius: 4,    backgroundColor: "#fbbf24",  },});

Props

Tab

React Native Reanimated
React Native Blur