ComponentsNavigation

Expandable Action Menu

A fluid tab bar that expands vertically when the center FAB is pressed. It features a sliding pill indicator for active tabs, a smoothly rotating floating action button, and a frosted-glass-like action menu with cascading entry animations.

v
Updated

Live Preview

Open in Snack
Preview

Installation

1
npm install react-native-reanimated lucide-react-native
2

Copy ExpandableActionMenu.tsx to your components folder

3

Import and use in your layout

Source Code

ExpandableActionMenu.tsx
import React, { useState, useEffect } from 'react';
import {
    View,
    Text,
    StyleSheet,
    Pressable,
    Dimensions,
} from 'react-native';
import Animated, {
    useAnimatedStyle,
    useSharedValue,
    withSpring,
    withTiming,
    interpolate,
    Extrapolate,
    interpolateColor,
} from 'react-native-reanimated';
import { Home, User, Plus, MessageCircle, Users, Archive, Search, Bell, ChevronRight } from 'lucide-react-native';

const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');

const TAB_BAR_HEIGHT = 80;
const FAB_SIZE = 56;
const SPRING_CONFIG = { damping: 15, stiffness: 180, mass: 1 };

const TabItem = ({ name, activeTab, onTabPress, IconComponent }: any) => {
    const isActive = activeTab === name;

    const scale = useSharedValue(isActive ? 1.1 : 1);

    useEffect(() => {
        scale.value = withSpring(isActive ? 1.1 : 1, { damping: 16, stiffness: 120 });
    }, [isActive]);

    const animatedStyle = useAnimatedStyle(() => ({
        transform: [{ scale: scale.value }],
    }));

    return (
        <Pressable style={styles.tabItem} onPress={() => onTabPress(name)}>
            <Animated.View style={[styles.iconWrapper, animatedStyle]}>
                <IconComponent size={24} color={isActive ? "#18181b" : "#52525b"} strokeWidth={isActive ? 2.5 : 2} />
            </Animated.View>
        </Pressable>
    );
};

const TABS = ['home', 'search', 'bell', 'user'];

export default function ExpandableActionMenu() {
    const [expanded, setExpanded] = useState(false);
    const [activeTab, setActiveTab] = useState('home');
    const progress = useSharedValue(0);
    const activeIndexAnim = useSharedValue(0);

    const TAB_WIDTH = (SCREEN_WIDTH - 24 - 80) / 4;

    const handleTabPress = (name: string) => {
        setActiveTab(name);
        const index = TABS.indexOf(name);
        // Smoother, less bouncy slide
        activeIndexAnim.value = withSpring(index, { damping: 20, stiffness: 150, mass: 1 });
    };

    const toggleExpand = () => {
        const newValue = expanded ? 0 : 1;
        setExpanded(!expanded);
        progress.value = withSpring(newValue, SPRING_CONFIG);
    };

    const backdropStyle = useAnimatedStyle(() => {
        return {
            opacity: progress.value,
            pointerEvents: progress.value > 0 ? 'auto' : 'none',
        };
    });

    const fabStyle = useAnimatedStyle(() => {
        return {
            transform: [
                { translateY: interpolate(progress.value, [0, 1], [0, 32]) },
                { rotate: `${progress.value * 45}deg` }
            ],
            backgroundColor: '#18181b' // Keep it black for bold contrast
        };
    });

    const iconWhiteStyle = useAnimatedStyle(() => ({
        opacity: 1 // Always show the white icon
    }));

    const iconBlackStyle = useAnimatedStyle(() => ({
        opacity: 0
    }));

    const tabBarStyle = useAnimatedStyle(() => {
        return {
            height: interpolate(progress.value, [0, 1], [TAB_BAR_HEIGHT, 310]),
            borderTopLeftRadius: interpolate(progress.value, [0, 1], [24, 36]),
            borderTopRightRadius: interpolate(progress.value, [0, 1], [24, 36]),
        };
    });

    const otherIconsStyle = useAnimatedStyle(() => {
        return {
            opacity: interpolate(progress.value, [0, 0.3], [1, 0]),
            transform: [
                { translateY: interpolate(progress.value, [0, 0.3], [0, 20]) }
            ],
            pointerEvents: progress.value > 0.1 ? 'none' : 'auto',
        };
    });

    const menuContentStyle = useAnimatedStyle(() => {
        return {
            pointerEvents: progress.value > 0.5 ? 'auto' : 'none',
        };
    });

    const handleStyle = useAnimatedStyle(() => ({
        opacity: interpolate(progress.value, [0.5, 1], [0, 1], Extrapolate.CLAMP),
        transform: [{ translateY: interpolate(progress.value, [0.5, 1], [10, 0], Extrapolate.CLAMP) }]
    }));

    const item1Style = useAnimatedStyle(() => ({
        opacity: interpolate(progress.value, [0.3, 0.8], [0, 1], Extrapolate.CLAMP),
        transform: [{ translateY: interpolate(progress.value, [0.3, 0.8], [30, 0], Extrapolate.CLAMP) }]
    }));

    const item2Style = useAnimatedStyle(() => ({
        opacity: interpolate(progress.value, [0.4, 0.9], [0, 1], Extrapolate.CLAMP),
        transform: [{ translateY: interpolate(progress.value, [0.4, 0.9], [30, 0], Extrapolate.CLAMP) }]
    }));

    const item3Style = useAnimatedStyle(() => ({
        opacity: interpolate(progress.value, [0.5, 1.0], [0, 1], Extrapolate.CLAMP),
        transform: [{ translateY: interpolate(progress.value, [0.5, 1.0], [30, 0], Extrapolate.CLAMP) }]
    }));

    const indicatorStyle = useAnimatedStyle(() => {
        const translateX = interpolate(
            activeIndexAnim.value,
            [0, 1, 2, 3],
            [
                12 + 4,
                12 + TAB_WIDTH + 4,
                12 + TAB_WIDTH * 2 + 80 + 4,
                12 + TAB_WIDTH * 3 + 80 + 4
            ]
        );
        const translateY = interpolate(progress.value, [0, 0.3], [0, 20]);
        return {
            transform: [{ translateX }, { translateY }],
            // Apply the same opacity/translate as otherIconsStyle so it hides when modal opens
            opacity: interpolate(progress.value, [0, 0.3], [1, 0]),
        };
    });

    return (
        <View style={styles.container}>
            {/* Backdrop */}
            <Animated.View style={[styles.backdrop, backdropStyle]}>
                <Pressable style={StyleSheet.absoluteFill} onPress={toggleExpand} />
            </Animated.View>

            {/* Expanding Tab Bar Background */}
            <Animated.View style={[styles.tabBarBackground, tabBarStyle]}>

                {/* Expanded Menu Content */}
                <Animated.View style={[styles.menuContainer, menuContentStyle]}>
                    <Animated.View style={[styles.dragHandle, handleStyle]} />

                    <Animated.View style={item1Style}>
                        <Pressable style={({ pressed }: { pressed: boolean }) => [styles.menuItem, pressed && styles.menuItemPressed]}>
                            <View style={[styles.menuIcon, { backgroundColor: '#e0f2fe' }]}>
                                <Archive size={22} color="#0284c7" strokeWidth={2.5} />
                            </View>
                            <View style={styles.menuTextContainer}>
                                <Text style={styles.menuTitle}>Archived</Text>
                                <Text style={styles.menuSubtitle}>See hidden chats</Text>
                            </View>
                            <ChevronRight size={20} color="#d4d4d8" />
                        </Pressable>
                    </Animated.View>

                    <Animated.View style={[styles.separator, item2Style]} />

                    <Animated.View style={item2Style}>
                        <Pressable style={({ pressed }: { pressed: boolean }) => [styles.menuItem, pressed && styles.menuItemPressed]}>
                            <View style={[styles.menuIcon, { backgroundColor: '#dcfce7' }]}>
                                <Users size={22} color="#16a34a" strokeWidth={2.5} />
                            </View>
                            <View style={styles.menuTextContainer}>
                                <Text style={styles.menuTitle}>New Contacts</Text>
                                <Text style={styles.menuSubtitle}>Add a new friend</Text>
                            </View>
                            <ChevronRight size={20} color="#d4d4d8" />
                        </Pressable>
                    </Animated.View>

                    <Animated.View style={[styles.separator, item3Style]} />

                    <Animated.View style={item3Style}>
                        <Pressable style={({ pressed }: { pressed: boolean }) => [styles.menuItem, pressed && styles.menuItemPressed]}>
                            <View style={[styles.menuIcon, { backgroundColor: '#f3e8ff' }]}>
                                <MessageCircle size={22} color="#9333ea" strokeWidth={2.5} />
                            </View>
                            <View style={styles.menuTextContainer}>
                                <Text style={styles.menuTitle}>New Chat</Text>
                                <Text style={styles.menuSubtitle}>Start a new conversation</Text>
                            </View>
                            <ChevronRight size={20} color="#d4d4d8" />
                        </Pressable>
                    </Animated.View>
                </Animated.View>

                {/* Icons Row (Bottom) */}
                <View style={styles.iconsRow}>

                    {/* Sliding Pill Indicator */}
                    <Animated.View style={[styles.slidingIndicator, indicatorStyle, { width: TAB_WIDTH - 8 }]} />

                    <Animated.View style={[styles.sideIcons, otherIconsStyle]}>
                        <TabItem name="home" activeTab={activeTab} onTabPress={handleTabPress} IconComponent={Home} />
                        <TabItem name="search" activeTab={activeTab} onTabPress={handleTabPress} IconComponent={Search} />
                    </Animated.View>

                    <View style={styles.centerTabItem}>
                        <Pressable style={styles.fabContainer} onPress={toggleExpand}>
                            <Animated.View style={[styles.fab, fabStyle]}>
                                <Animated.View style={[StyleSheet.absoluteFill, { alignItems: 'center', justifyContent: 'center' }, iconWhiteStyle]}>
                                    <Plus size={28} color="#ffffff" strokeWidth={2.5} />
                                </Animated.View>
                                <Animated.View style={[StyleSheet.absoluteFill, { alignItems: 'center', justifyContent: 'center' }, iconBlackStyle]}>
                                    <Plus size={28} color="#18181b" strokeWidth={2.5} />
                                </Animated.View>
                            </Animated.View>
                        </Pressable>
                    </View>

                    <Animated.View style={[styles.sideIcons, otherIconsStyle]}>
                        <TabItem name="bell" activeTab={activeTab} onTabPress={handleTabPress} IconComponent={Bell} />
                        <TabItem name="user" activeTab={activeTab} onTabPress={handleTabPress} IconComponent={User} />
                    </Animated.View>
                </View>
            </Animated.View>
        </View>
    );
}

const styles = StyleSheet.create({
    container: {
        position: 'absolute',
        bottom: 0,
        left: 0,
        right: 0,
        alignItems: 'center',
    },
    backdrop: {
        position: 'absolute',
        bottom: 0,
        left: -SCREEN_WIDTH,
        right: -SCREEN_WIDTH,
        height: SCREEN_HEIGHT,
        backgroundColor: 'rgba(0, 0, 0, 0.4)',
    },
    tabBarBackground: {
        position: 'absolute',
        bottom: 0,
        width: '100%',
        backgroundColor: '#ffffff',
        shadowColor: '#000',
        shadowOffset: { width: 0, height: -4 },
        shadowOpacity: 0.1,
        shadowRadius: 16,
        elevation: 10,
    },
    iconsRow: {
        position: 'absolute',
        bottom: 0,
        flexDirection: 'row',
        height: TAB_BAR_HEIGHT,
        width: '100%',
        alignItems: 'center',
        paddingHorizontal: 12,
        paddingBottom: 20, // SafeArea spacing
    },
    sideIcons: {
        flex: 1,
        flexDirection: 'row',
    },
    tabItem: {
        flex: 1,
        alignItems: 'center',
        justifyContent: 'center',
    },
    iconWrapper: {
        alignItems: 'center',
        justifyContent: 'center',
        height: 52,
    },
    slidingIndicator: {
        position: 'absolute',
        top: 4, // 60px effective height, centered is 30, half of 52 is 26, 30-26=4
        height: 52,
        borderRadius: 26,
        backgroundColor: '#f4f4f5',
    },
    centerTabItem: {
        width: 80,
        alignItems: 'center',
        justifyContent: 'center',
    },
    fabContainer: {
        position: 'absolute',
        top: -48, // Float half outside the tab bar
        left: (80 - 56) / 2, // 12
        width: 56,
        height: 56,
        alignItems: 'center',
        justifyContent: 'center',
    },
    fab: {
        width: FAB_SIZE,
        height: FAB_SIZE,
        borderRadius: FAB_SIZE / 2,
        alignItems: 'center',
        justifyContent: 'center',
        shadowColor: '#000',
        shadowOffset: { width: 0, height: 6 },
        shadowOpacity: 0.25,
        shadowRadius: 10,
        elevation: 8,
    },
    menuContainer: {
        flex: 1,
        paddingTop: 12,
        paddingHorizontal: 16,
        paddingBottom: 80, // Reduced since FAB shifts down
    },
    dragHandle: {
        width: 36,
        height: 5,
        borderRadius: 2.5,
        backgroundColor: '#e4e4e7',
        alignSelf: 'center',
        marginBottom: 16,
    },
    menuItem: {
        flexDirection: 'row',
        alignItems: 'center',
        paddingVertical: 14,
        paddingHorizontal: 16,
        borderRadius: 20,
    },
    separator: {
        height: 1,
        backgroundColor: '#f4f4f5',
        marginLeft: 76, // Align with the text, skipping the icon
        marginRight: 16,
    },
    menuItemPressed: {
        backgroundColor: '#f4f4f5',
    },
    menuIcon: {
        width: 44,
        height: 44,
        borderRadius: 22,
        backgroundColor: '#f4f4f5',
        alignItems: 'center',
        justifyContent: 'center',
        marginRight: 16,
    },
    menuTextContainer: {
        flex: 1,
    },
    menuTitle: {
        fontSize: 16,
        fontWeight: '600',
        color: '#18181b',
        marginBottom: 2,
        letterSpacing: -0.2,
    },
    menuSubtitle: {
        fontSize: 13,
        fontWeight: '400',
        color: '#a1a1aa',
    },
});

Usage Examples

Basic Usage

Render the expandable action menu at the bottom of your screen.

Example Code
import ExpandableActionMenu from '@/components/ExpandableActionMenu';

export default function App() {
  return (
    <View style={{ flex: 1 }}>
      {/* Main Content */}
      <ExpandableActionMenu />
    </View>
  );
}

Features

  • Liquid sliding pill indicator for active tab
  • Fluid morphing layout expanding vertically
  • Perfectly centered FAB that spins into an 'X'
  • Cascading staggered menu item entrance animations
  • Fully powered by react-native-reanimated spring physics

Dependencies

Required

react-native-reanimatedlucide-react-native