Stack Aware Tabs
A modern bottom tab bar with animated focus scaling
Last updated on
Manual
Install the following dependencies:
npm install react-native-reanimated react-native-gesture-handler react-native-workletsCopy and paste the following code into your project.
component/base/stack-aware-tabs.tsx
import React, { memo, useCallback } from "react";import { StyleSheet, View, TouchableOpacity, type ViewStyle,} from "react-native";import Animated, { useAnimatedStyle, withSpring, useSharedValue, withTiming, interpolate, useAnimatedProps,} from "react-native-reanimated";import { Gesture, GestureDetector, PanGesture, TapGesture,} from "react-native-gesture-handler";import { scheduleOnRN } from "react-native-worklets";import { Ionicons } from "@expo/vector-icons";import * as Haptics from "expo-haptics";import { useRouter } from "expo-router";import { BlurView, BlurViewProps } from "expo-blur";import type { BottomTabBarProps } from "@react-navigation/bottom-tabs";import type { TabBarProps } from "./types";const SPACING = 10;const SCALE_UP = 1.2;const AnimatedBlurView = Animated.createAnimatedComponent<BlurViewProps>(BlurView);const TabButton: React.FC<TabBarProps> & React.FunctionComponent<TabBarProps> = memo( ({ onPress, onLongPress, isFocused, label, icon, index, activeIndex }) => { const scale = useSharedValue(1); const opacity = useSharedValue(0.6); React.useEffect(() => { scale.value = withSpring(isFocused ? 1.1 : 1, {}); opacity.value = withTiming(isFocused ? 1 : 0.6, { duration: 200 }); }, [isFocused]); const animatedStyle = useAnimatedStyle(() => { const isActive = activeIndex.value === index; const isCurrentlyFocused = isFocused && activeIndex.value === -1; return { transform: [ { scale: withSpring( isActive ? SCALE_UP : isCurrentlyFocused ? 1.1 : 1, {}, ), }, ], opacity: withTiming(isActive ? 1 : isCurrentlyFocused ? 1 : 0.6, { duration: 200, }), zIndex: isActive ? 1000 : index, }; }); const handlePressIn = () => { if (activeIndex.value === -1) { scale.value = withSpring(0.85, {}); } }; const handlePressOut = () => { if (activeIndex.value === -1) { scale.value = withSpring(isFocused ? 1.1 : 1, {}); } }; return ( <TouchableOpacity onPress={onPress} onLongPress={onLongPress} onPressIn={handlePressIn} onPressOut={handlePressOut} style={styles.tabButton} activeOpacity={1} > <Animated.View style={[styles.iconContainer, animatedStyle]}> {icon?.({ focused: isFocused || activeIndex.value === index, color: isFocused || activeIndex.value === index ? "#fff" : "#6b7280", size: 28, })} </Animated.View> </TouchableOpacity> ); }, );export const StackAwareTabBar: React.FC<BottomTabBarProps> & React.FunctionComponent<BottomTabBarProps> = memo( (props: BottomTabBarProps): JSX.Element & React.ReactNode => { const router = useRouter(); const { state, descriptors, navigation } = props; const translateX = useSharedValue<number>(100); const opacity = useSharedValue<number>(0); const activeIndex = useSharedValue<number>(-1); const lastHapticIndex = useSharedValue<number>(-1); const tabBarWidth = useSharedValue<number>(0); const currentRoute = state.routes[state.index]; const hasNestedRoutes = currentRoute?.state?.index !== undefined && currentRoute.state.index > 0; React.useEffect((): void => { if (hasNestedRoutes) { translateX.value = withSpring(0, { damping: 18, stiffness: 130, mass: 0.6, }); opacity.value = withTiming(1, { duration: 200 }); } else { translateX.value = withSpring(100, { damping: 18, stiffness: 130, mass: 0.6, }); opacity.value = withTiming(0, { duration: 200 }); } }, [hasNestedRoutes]); const backButtonStyle = useAnimatedStyle<ViewStyle>(() => ({ transform: [ { translateX: translateX.value }, { scale: interpolate(opacity.value, [0, 1], [0, 1]), }, { rotate: withSpring( `${interpolate(opacity.value, [0, 1], [100, 0])}deg`, ), }, ], opacity: opacity.value, })); const handleBackPress = () => { router.back(); }; const triggerHaptic = useCallback<() => void>(() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); }, []); const calculateActiveIndex = useCallback( (x: number, totalWidth: number) => { "worklet"; const tabCount = state.routes.length; const tabWidth = totalWidth / tabCount; for (let i = 0; i < tabCount; i++) { const tabStart = i * tabWidth; const tabEnd = tabStart + tabWidth; if (x >= tabStart && x <= tabEnd) { return i; } } if (x > totalWidth) { return tabCount - 1; } return 0; }, [state.routes.length], ); const navigateToTab = useCallback<(index: number) => void>( (index: number) => { const route = state.routes[index]; const event = navigation.emit({ type: "tabPress", target: route.key, canPreventDefault: true, }); if (state.index !== index && !event.defaultPrevented) { navigation.navigate(route.name); } }, [state, navigation], ); const panGesture: PanGesture = Gesture.Pan() .minDistance(0) .onBegin((event) => { "worklet"; const width = tabBarWidth.value; if (width > 0) { const index = calculateActiveIndex(event.x, width); activeIndex.value = index; lastHapticIndex.value = index; scheduleOnRN(triggerHaptic); } }) .onUpdate((event) => { "worklet"; const width = tabBarWidth.value; if (width > 0) { const index = calculateActiveIndex(event.x, width); if (index !== activeIndex.value) { activeIndex.value = index; if (index !== lastHapticIndex.value) { lastHapticIndex.value = index; scheduleOnRN(triggerHaptic); } } } }) .onEnd(() => { "worklet"; const finalIndex = activeIndex.value; if (finalIndex >= 0 && finalIndex < state.routes.length) { scheduleOnRN(navigateToTab, finalIndex); } activeIndex.value = -1; lastHapticIndex.value = -1; }) .onFinalize(() => { "worklet"; activeIndex.value = -1; lastHapticIndex.value = -1; }); const tapGesture: TapGesture = Gesture.Tap() .maxDuration(100) .onEnd((event) => { const width = tabBarWidth.value; if (width > 0) { const index = calculateActiveIndex(event.x, width); if (index >= 0 && index < state.routes.length) { scheduleOnRN(navigateToTab, index); } } }); const composedGesture = Gesture.Exclusive(panGesture, tapGesture); const animatedBlurViewPropz = useAnimatedProps< Pick<BlurViewProps, "intensity"> >(() => { const intensity = withSpring( interpolate(opacity.value, [0, 0.3, 0.5, 1], [0, 5.5, 94.5, 0]), ); return { intensity, }; }); return ( <View style={styles.container}> <View style={styles.wrapper}> <Animated.View style={[styles.backButtonContainer, backButtonStyle]}> <TouchableOpacity style={styles.backButton} onPress={handleBackPress} activeOpacity={0.7} > <Ionicons name="chevron-back" size={20} color="#000" /> </TouchableOpacity> <AnimatedBlurView style={[ StyleSheet.absoluteFillObject, { overflow: "hidden", borderRadius: 25, }, ]} animatedProps={animatedBlurViewPropz} tint={"systemUltraThinMaterialLight"} pointerEvents={"none"} /> </Animated.View> <GestureDetector gesture={composedGesture}> <View style={styles.tabBar} onLayout={(event) => { tabBarWidth.value = event.nativeEvent.layout.width; }} > {state.routes.map((route, index) => { const { options } = descriptors[route.key]; const label = options.tabBarLabel !== undefined ? String(options.tabBarLabel) : options.title !== undefined ? options.title : route.name; const isFocused = state.index === index; const onPress = (): void => { const event = navigation.emit({ type: "tabPress", target: route.key, canPreventDefault: true, }); if (!isFocused && !event.defaultPrevented) { navigation.navigate(route.name); } }; const onLongPress = (): void => { navigation.emit({ type: "tabLongPress", target: route.key, }); }; return ( <TabButton key={route.key} onPress={onPress} onLongPress={onLongPress} isFocused={isFocused} label={label} icon={options.tabBarIcon} index={index} activeIndex={activeIndex} /> ); })} </View> </GestureDetector> </View> </View> ); },);export default memo(StackAwareTabBar);const styles = StyleSheet.create({ tabBar: { flexDirection: "row", backgroundColor: "#101010", borderRadius: 50, paddingVertical: 15, maxWidth: 200, paddingHorizontal: SPACING * 2, shadowColor: "#000", shadowOffset: { width: 0, height: 2, }, shadowOpacity: 0.1, shadowRadius: 8, elevation: 4, }, tabButton: { flex: 1, alignItems: "center", justifyContent: "center", }, iconContainer: { alignItems: "center", justifyContent: "center", }, label: { fontSize: 11, fontWeight: "600", marginTop: 2, }, container: { paddingBottom: 20, backgroundColor: "transparent", justifyContent: "center", alignItems: "center", }, wrapper: { flexDirection: "row", alignItems: "center", gap: 12, }, backButtonContainer: { position: "absolute", left: -SPACING / 2 - 50, }, backButton: { width: 40, height: 40, borderRadius: 25, backgroundColor: "#ffffff", justifyContent: "center", alignItems: "center", shadowColor: "#000", shadowOffset: { width: 0, height: 2, }, shadowOpacity: 0.1, shadowRadius: 8, elevation: 4, },});Usage
import React from "react";import { Tabs } from "expo-router";import { Ionicons } from "@expo/vector-icons";import { GestureHandlerRootView } from "react-native-gesture-handler";import { StackAwareTabBar } from "@/components/base/stack-aware-tabs";export default function TabLayout() { return ( <GestureHandlerRootView style={{ flex: 1 }}> <Tabs tabBar={(props) => <StackAwareTabBar {...props} />} screenOptions={{ headerShown: true, headerTitle: "Glow UI", }} > <Tabs.Screen name="(first)" options={{ title: "Home", tabBarIcon: ({ focused, color, size }) => ( <Ionicons name={focused ? "home" : "home-outline"} size={20} color={focused ? "#FFFFFF" : "#B9B9B9"} /> ), }} /> <Tabs.Screen name="(second)" options={{ title: "Search", tabBarIcon: ({ focused, color, size }) => ( <Ionicons name={focused ? "search" : "search-outline"} size={20} color={focused ? "#FFFFFF" : "#B9B9B9"} /> ), }} /> <Tabs.Screen name="(third)" options={{ title: "Profile", tabBarIcon: ({ focused, color, size }) => ( <Ionicons name={focused ? "person" : "person-outline"} size={20} color={focused ? "#FFFFFF" : "#B9B9B9"} /> ), }} /> </Tabs> </GestureHandlerRootView> );}Props
React Native Reanimated
React Native Worklets
React Native Gesture Handler
