'react native top tab bar navigator: indicator width to match text

I have three tabs in a top tab bar navigation with different width text. Is it possible to make the indicator width match the text? On a similar note, how can I make the tabs match the width of the text too without making it display weird. I've tried width auto but it doesn't stay center.

This is how it looks with auto width: enter image description here

    <Tab.Navigator 
                initialRouteName="Open"
                tabBarOptions={{
                    style: { 
                      backgroundColor: "white", 
                      paddingTop: 20, 
                      paddingHorizontal: 25
                      },
                    indicatorStyle: {
                      borderBottomColor: colorScheme.teal,
                      borderBottomWidth: 2,
                      width: '30%',
                      left:"9%"
                    },
                    tabStyle : {
                      justifyContent: "center",
                      width: tabBarWidth/3,
                    }
                  }}
            >
                <Tab.Screen 
                    name="Open" 
                    component={WriterRequestScreen} 
                    initialParams={{ screen: 'Open' }} 
                    options={{ 
                      tabBarLabel: ({focused}) => <Text style = {{fontSize: 18, fontWeight: 'bold', color: focused? colorScheme.teal : colorScheme.grey}}> Open </Text>, 
                   }}
                    />
               <Tab.Screen 
                    name="In Progress" 
                    component={WriterRequestScreen} 
                    initialParams={{ screen: 'InProgress' }}
                    options={{ 
                      tabBarLabel: ({focused}) => <Text style = {{fontSize: 18, fontWeight: 'bold', color: focused? colorScheme.teal : colorScheme.grey}}> In Progress </Text>}}
                    />
               <Tab.Screen 
                name="Completed" 
                component={WriterRequestScreen} 
                initialParams={{ screen: 'Completed' }}
                options={{ tabBarLabel: ({focused}) => <Text style = {{fontSize: 18, fontWeight: 'bold', color: focused? colorScheme.teal : colorScheme.grey}}> Completed </Text>}}
                />
            </Tab.Navigator>


Solution 1:[1]

I also needed to make the indicator fit the text size, a dynamic width for the labels, and a scrollable top bar because of long labels. The result looks like this:

tab bar with dynamic indicator width

If you don't care about the indicator width fitting the labels, you can simply use screenOptions.tabBarScrollEnabled: true in combination with width: "auto" in screenOptions.tabBarIndicatorStyle.

Otherwise, you'll need to make your own tab bar component and pass it to the tabBar property of your <Tab.Navigator>. I used a ScrollView but if you have only a few tabs with short labels, a View would be more simple. Here is the Typescript code for this custom TabBar component:

import { MaterialTopTabBarProps } from "@react-navigation/material-top-tabs";
import { useEffect, useRef, useState } from "react";
import {
  Animated,
  Dimensions,
  View,
  TouchableOpacity,
  StyleSheet,
  ScrollView,
  I18nManager,
  LayoutChangeEvent,
} from "react-native";

const screenWidth = Dimensions.get("window").width;

const DISTANCE_BETWEEN_TABS = 20;

const TabBar = ({
  state,
  descriptors,
  navigation,
  position,
}: MaterialTopTabBarProps): JSX.Element => {
  const [widths, setWidths] = useState<(number | undefined)[]>([]);
  const scrollViewRef = useRef<ScrollView>(null);
  const transform = [];
  const inputRange = state.routes.map((_, i) => i);

  // keep a ref to easily scroll the tab bar to the focused label
  const outputRangeRef = useRef<number[]>([]);

  const getTranslateX = (
    position: Animated.AnimatedInterpolation,
    routes: never[],
    widths: number[]
  ) => {
    const outputRange = routes.reduce((acc, _, i: number) => {
      if (i === 0) return [DISTANCE_BETWEEN_TABS / 2 + widths[0] / 2];
      return [
        ...acc,
        acc[i - 1] + widths[i - 1] / 2 + widths[i] / 2 + DISTANCE_BETWEEN_TABS,
      ];
    }, [] as number[]);
    outputRangeRef.current = outputRange;
    const translateX = position.interpolate({
      inputRange,
      outputRange,
      extrapolate: "clamp",
    });
    return Animated.multiply(translateX, I18nManager.isRTL ? -1 : 1);
  };

  // compute translateX and scaleX because we cannot animate width directly
  if (
    state.routes.length > 1 &&
    widths.length === state.routes.length &&
    !widths.includes(undefined)
  ) {
    const translateX = getTranslateX(
      position,
      state.routes as never[],
      widths as number[]
    );
    transform.push({
      translateX,
    });
    const outputRange = inputRange.map((_, i) => widths[i]) as number[];
    transform.push({
      scaleX:
        state.routes.length > 1
          ? position.interpolate({
              inputRange,
              outputRange,
              extrapolate: "clamp",
            })
          : outputRange[0],
    });
  }

  // scrolls to the active tab label when a new tab is focused
  useEffect(() => {
    if (
      state.routes.length > 1 &&
      widths.length === state.routes.length &&
      !widths.includes(undefined)
    ) {
      if (state.index === 0) {
        scrollViewRef.current?.scrollTo({
          x: 0,
        });
      } else {
        // keep the focused label at the center of the screen
        scrollViewRef.current?.scrollTo({
          x: (outputRangeRef.current[state.index] as number) - screenWidth / 2,
        });
      }
    }
  }, [state.index, state.routes.length, widths]);

  // get the label widths on mount
  const onLayout = (event: LayoutChangeEvent, index: number) => {
    const { width } = event.nativeEvent.layout;
    const newWidths = [...widths];
    newWidths[index] = width - DISTANCE_BETWEEN_TABS;
    setWidths(newWidths);
  };

  // basic labels as suggested by react navigation
  const labels = state.routes.map((route, index) => {
    const { options } = descriptors[route.key];
    const label = route.name;
    const isFocused = state.index === index;

    const onPress = () => {
      const event = navigation.emit({
        type: "tabPress",
        target: route.key,
        canPreventDefault: true,
      });

      if (!isFocused && !event.defaultPrevented) {
        // The `merge: true` option makes sure that the params inside the tab screen are preserved
        // eslint-disable-next-line
        // @ts-ignore
        navigation.navigate({ name: route.name, merge: true });
      }
    };
    const inputRange = state.routes.map((_, i) => i);
    const opacity = position.interpolate({
      inputRange,
      outputRange: inputRange.map((i) => (i === index ? 1 : 0.5)),
    });

    return (
      <TouchableOpacity
        key={route.key}
        accessibilityRole="button"
        accessibilityState={isFocused ? { selected: true } : {}}
        accessibilityLabel={options.tabBarAccessibilityLabel}
        onPress={onPress}
        style={styles.button}
      >
        <View
          onLayout={(event) => onLayout(event, index)}
          style={styles.buttonContainer}
        >
          <Animated.Text style={[styles.text, { opacity }]}>
            {label}
          </Animated.Text>
        </View>
      </TouchableOpacity>
    );
  });

  return (
    <View style={styles.contentContainer}>
      <Animated.ScrollView
        horizontal
        ref={scrollViewRef}
        showsHorizontalScrollIndicator={false}
        style={styles.container}
      >
        {labels}
        <Animated.View style={[styles.indicator, { transform }]} />
      </Animated.ScrollView>
    </View>
  );
};

const styles = StyleSheet.create({
  button: {
    alignItems: "center",
    justifyContent: "center",
  },
  buttonContainer: {
    paddingHorizontal: DISTANCE_BETWEEN_TABS / 2,
  },
  container: {
    backgroundColor: "black",
    flexDirection: "row",
    height: 34,
  },
  contentContainer: {
    height: 34,
    marginTop: 30,
  },
  indicator: {
    backgroundColor: "white",
    bottom: 0,
    height: 3,
    left: 0,
    position: "absolute",
    right: 0,
    // this must be 1 for the scaleX animation to work properly
    width: 1,
  },
  text: {
    color: "white",
    fontSize: 14,
    textAlign: "center",
  },
});

export default TabBar;

I managed to make it work with a mix of:

Please let me know if you find a more convenient solution.

Solution 2:[2]

You have to add width:auto to tabStyle to make tab width flexible.

Then inside each tabBarLabel <Text> component add style textAlign: "center" and width: YOUR_WIDTH .

YOUR_WIDTH can be different for each tab and can be your text.length * 10 (if you want to make it depended on your text length) or get screen width from Dimensions and divide it by any other number to make it equal widths in screen. Example:

const win = Dimensions.get('window');

...

bigTab: {
    fontFamily: "Mulish-Bold",
    fontSize: 11,
    color: "#fff",
    textAlign: "center",
    width: win.width/2 - 40
},
smallTab: {
    fontFamily: "Mulish-Bold",
    fontSize: 11,
    color: "#fff",
    textAlign: "center",
    width: win.width / 5 + 10
}

Solution 3:[3]

Remove width from indicatorStyle and use flex:1

indicatorStyle: {    borderBottomColor: colorScheme.teal,
                      borderBottomWidth: 2,
                      flex:1,
                      left:"9%"
                    },

Solution 4:[4]

I've achieved this using some hacks around onLayout, please note I've made this with the assumptions of two tabs, and that the second tabs width is greater than the first. It probably will need tweaking for other use cases.

enter image description here

import React, { useEffect, useState } from 'react'
import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs'
import { Animated, Text, TouchableOpacity, View } from 'react-native'

const Stack = createMaterialTopTabNavigator()

const DISTANCE_BETWEEN_TABS = 25

function MyTabBar ({ state, descriptors, navigation, position }) {
  const [widths, setWidths] = useState([])
  const [transform, setTransform] = useState([])

  const inputRange = state.routes.map((_, i) => i)

  useEffect(() => {
    if (widths.length === 2) {
      const [startingWidth, transitionWidth] = widths
      const translateX = position.interpolate({
        inputRange,
        outputRange: [0, startingWidth + DISTANCE_BETWEEN_TABS + (transitionWidth - startingWidth) / 2]
      })
      const scaleX = position.interpolate({
        inputRange,
        outputRange: [1, transitionWidth / startingWidth]
      })
      setTransform([{ translateX }, { scaleX }])
    }
  }, [widths])

  return (
    <View style={{ flexDirection: 'row' }}>
      {state.routes.map((route, index) => {
        const { options } = descriptors[route.key]
        const label =
          options.tabBarLabel !== undefined
            ? options.tabBarLabel
            : options.title !== undefined
              ? options.title
              : route.name

        const isFocused = state.index === index

        const onPress = () => {
          const event = navigation.emit({
            type: 'tabPress',
            target: route.key,
            canPreventDefault: true
          })

          if (!isFocused && !event.defaultPrevented) {
            // The `merge: true` option makes sure that the params inside the tab screen are preserved
            navigation.navigate({ name: route.name, merge: true })
          }
        }

        const onLayout = event => {
          const { width } = event.nativeEvent.layout
          setWidths([...widths, width])
        }

        const opacity = position.interpolate({
          inputRange,
          outputRange: inputRange.map(i => (i === index ? 0.87 : 0.53))
        })

        return (
          <TouchableOpacity
            key={index}
            accessibilityRole='button'
            accessibilityState={isFocused ? { selected: true } : {}}
            accessibilityLabel={options.tabBarAccessibilityLabel}
            testID={options.tabBarTestID}
            onPress={onPress}
            style={{ marginRight: DISTANCE_BETWEEN_TABS }}
          >
            <Animated.Text
              onLayout={onLayout}
              style={{
                opacity,
                color: '#000',
                fontSize: 18,
                fontFamily: 'OpenSans-Bold',
                marginBottom: 15
              }}
            >
              {label}
            </Animated.Text>
          </TouchableOpacity>
        )
      })}
      <View style={{ backgroundColor: '#DDD', height: 2, position: 'absolute', bottom: 0, left: 0, right: 0 }} />
      <Animated.View style={{ position: 'absolute', bottom: 0, left: 0, width: widths.length ? widths[0] : 0, backgroundColor: '#222', height: 2, transform }} />
    </View>
  )
}

export default () => {
  return (
    <>
      <Stack.Navigator tabBar={props => <MyTabBar {...props} />} style={{ paddingHorizontal: 25 }}>
        <Stack.Screen name='Orders' component={() => <Text>A</Text>} />
        <Stack.Screen name='Reviews' component={() => <Text>B</Text>} />
      </Stack.Navigator>
    </>
  )
}

Update:

If the menu names are static, it is probably a more robust solution to hard code the widths inside of widths, although this is a little more costly to maintain.

Resources:

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1
Solution 2 Tadas Stra
Solution 3 Ahmed Elabd
Solution 4