Morphing Tab Bar
Tabs transition fluidly as selection changes
Last updated on
Manual
Install the following dependencies:
npm install react-native-reanimated @shopify/react-native-skia expo-hapticsCopy and paste the following code into your project.
component/molecules/morphing-tabbar.tsx
// @ts-checkimport React, { useState, useCallback, memo } from "react";import { View, Text, Pressable, StyleSheet, useColorScheme, Platform,} from "react-native";import type { LayoutRectangle, ViewStyle, PressableProps, LayoutChangeEvent,} from "react-native";import { Canvas, RoundedRect, Group, Shadow, BackdropBlur, Fill, rect, rrect,} from "@shopify/react-native-skia";import Animated, { useSharedValue, useAnimatedStyle, withTiming, Easing, interpolate,} from "react-native-reanimated";import type { IBackground, IMorphicTabBar, ITab } from "./types";import { DEFAULT_DARK_THEME, DEFAULT_ITEMS, DEFAULT_LIGHT_THEME } from "./conf";import { AndroidHaptics, impactAsync, ImpactFeedbackStyle, performAndroidHapticsAsync,} from "expo-haptics";const ANIMATION_EASING = Easing.bezier(0.4, 0, 0.2, 1);const AnimatedPressable = Animated.createAnimatedComponent<PressableProps>(Pressable);const Background: React.FC<IBackground> & React.FunctionComponent<IBackground> = memo<IBackground>( ({ width, height, borderRadius, theme, enableGlass, enableShadow, }: IBackground): | (React.ReactNode & React.JSX.Element & React.ReactElement) | null => { if (width === 0 || height === 0) return null; return ( <Canvas style={[StyleSheet.absoluteFill, { width, height }]}> <Group> {enableGlass && ( <> <BackdropBlur blur={10} clip={rrect( rect(0, 0, width, height), borderRadius, borderRadius, )} > <Fill color={theme.glassBackground || "rgba(255, 255, 255, 0.1)"} /> </BackdropBlur> </> )} {enableShadow && ( <RoundedRect x={0} y={0} width={width} height={height} r={borderRadius} color="transparent" > <Shadow dx={0} dy={4} blur={12} color={theme.shadowColor || "rgba(0, 0, 0, 0.2)"} /> </RoundedRect> )} </Group> </Canvas> ); }, );const Tab: React.FC<ITab> & React.FunctionComponent<ITab> = memo<ITab>( ({ item, index, activeIndex, totalItems, onPress, animationProgress, previousIndex, theme, borderRadius, textStyle, }: ITab): React.ReactNode & React.JSX.Element & React.ReactElement => { const isActive = index === activeIndex; const isFirst = index === 0; const isLast = index === totalItems - 1; const animatedContainerStylez = useAnimatedStyle< Pick< ViewStyle, | "borderTopLeftRadius" | "borderBottomLeftRadius" | "borderTopRightRadius" | "borderBottomRightRadius" | "marginHorizontal" > >(() => { const progress = animationProgress.value; const prevIdx = previousIndex.value; const wasActive = prevIdx === index; const willBeActive = activeIndex === index; let leftRadius: number; if (willBeActive) { const fromRadius = wasActive ? borderRadius : prevIdx === index - 1 || isFirst ? borderRadius : 0; leftRadius = interpolate(progress, [0, 1], [fromRadius, borderRadius]); } else if (wasActive) { const toRadius = activeIndex === index - 1 || isFirst ? borderRadius : 0; leftRadius = interpolate(progress, [0, 1], [borderRadius, toRadius]); } else { const shouldBeRounded = activeIndex === index - 1 || isFirst; const wasRounded = prevIdx === index - 1 || isFirst; if (shouldBeRounded !== wasRounded) { leftRadius = interpolate( progress, [0, 1], [wasRounded ? borderRadius : 0, shouldBeRounded ? borderRadius : 0], ); } else { leftRadius = shouldBeRounded ? borderRadius : 0; } } let rightRadius: number; if (willBeActive) { const fromRadius = wasActive ? borderRadius : prevIdx === index + 1 || isLast ? borderRadius : 0; rightRadius = interpolate(progress, [0, 1], [fromRadius, borderRadius]); } else if (wasActive) { const toRadius = activeIndex === index + 1 || isLast ? borderRadius : 0; rightRadius = interpolate(progress, [0, 1], [borderRadius, toRadius]); } else { const shouldBeRounded = activeIndex === index + 1 || isLast; const wasRounded = prevIdx === index + 1 || isLast; if (shouldBeRounded !== wasRounded) { rightRadius = interpolate( progress, [0, 1], [wasRounded ? borderRadius : 0, shouldBeRounded ? borderRadius : 0], ); } else { rightRadius = shouldBeRounded ? borderRadius : 0; } } let marginH: number; if (willBeActive && !wasActive) { marginH = interpolate(progress, [0, 1], [0, 8]); } else if (wasActive && !willBeActive) { marginH = interpolate(progress, [0, 1], [8, 0]); } else if (willBeActive && wasActive) { marginH = 8; } else { marginH = 0; } return { borderTopLeftRadius: leftRadius, borderBottomLeftRadius: leftRadius, borderTopRightRadius: rightRadius, borderBottomRightRadius: rightRadius, marginHorizontal: marginH, }; }, [activeIndex, borderRadius, isFirst, isLast]); const scaleValue = useSharedValue<number>(1); const handlePressIn = useCallback<() => void>(() => { scaleValue.value = withTiming(0.95, { duration: 100 }); }, [scaleValue]); const handlePressOut = useCallback<() => void>(() => { scaleValue.value = withTiming<number>(1, { duration: 100 }); }, [scaleValue]); const animatedScaleStylez = useAnimatedStyle<Pick<ViewStyle, "transform">>( () => ({ transform: [{ scale: scaleValue.value }], }), ); const handleOnPress: () => void = () => { return onPress<number>(index); }; return ( <AnimatedPressable onPress={handleOnPress} onPressIn={handlePressIn} onPressOut={handlePressOut} style={[ styles.tab, { backgroundColor: theme.tabBackground }, animatedContainerStylez, animatedScaleStylez, ]} > <Text style={[ styles.tabText, { color: isActive ? theme.activeText : theme.inactiveText, fontWeight: isActive ? "600" : "400", }, textStyle, ]} > {item.name} </Text> </AnimatedPressable> ); },);export const MorphicTabBar: React.FC<IMorphicTabBar> & React.FunctionComponent<IMorphicTabBar> = memo<IMorphicTabBar>( ({ items = DEFAULT_ITEMS, onTabChange, initialActiveIndex = 0, animationDuration = 300, borderRadius = 12, light = DEFAULT_LIGHT_THEME, dark = DEFAULT_DARK_THEME, enableGlass = false, enableShadow = true, containerStyle, textStyle, }: IMorphicTabBar): | (React.ReactNode & React.JSX.Element & React.ReactElement) | null => { const colorScheme = useColorScheme(); const isDark = colorScheme === "dark"; const theme = isDark ? dark : light; const [activeIndex, setActiveIndex] = useState<number>(initialActiveIndex); const [containerLayout, setContainerLayout] = useState< Pick<LayoutRectangle, "width" | "height"> >({ width: 0, height: 0, }); const animationProgress = useSharedValue<number>(1); const previousIndex = useSharedValue<number>(initialActiveIndex); const handleContainerLayout = useCallback< (event: LayoutChangeEvent) => void >((event: LayoutChangeEvent) => { const { width, height } = event.nativeEvent.layout; setContainerLayout({ width, height }); }, []); const handleTabPress = useCallback<(index: number) => void>( (index: number) => { if (index === activeIndex) return; previousIndex.value = activeIndex; animationProgress.value = 0; animationProgress.value = withTiming<number>(1, { duration: animationDuration, easing: ANIMATION_EASING, }); setActiveIndex(index); if (Platform.OS === "ios") { impactAsync(ImpactFeedbackStyle.Soft); } else { performAndroidHapticsAsync(AndroidHaptics.Keyboard_Tap); } onTabChange?.<string, number>(items[index].keyPath, index); }, [ activeIndex, animationProgress, previousIndex, animationDuration, items, onTabChange, ], ); return ( <View style={[styles.container, containerStyle]}> <View style={styles.navWrapper}> <View style={[styles.navContainer, { borderRadius }]} onLayout={handleContainerLayout} > <Background width={containerLayout.width} height={containerLayout.height} borderRadius={borderRadius} theme={theme} enableGlass={enableGlass} enableShadow={enableShadow} /> <View style={styles.tabsRow}> {items.map<React.JSX.Element>((item, index) => ( <Tab key={`${item.keyPath}-${index}`} item={item} index={index} activeIndex={activeIndex} totalItems={items.length} onPress={handleTabPress} animationProgress={animationProgress} previousIndex={previousIndex} theme={theme} borderRadius={borderRadius} textStyle={textStyle} /> ))} </View> </View> </View> </View> ); },);const styles = StyleSheet.create({ container: { paddingHorizontal: 16, paddingVertical: 8, }, navWrapper: { alignItems: "center", justifyContent: "center", }, navContainer: { flexDirection: "row", overflow: "hidden", }, tabsRow: { flexDirection: "row", alignItems: "center", }, tab: { paddingHorizontal: 16, paddingVertical: 6, alignItems: "center", justifyContent: "center", }, tabText: { fontSize: 14, },});export default memo< React.FC<IMorphicTabBar> & React.FunctionComponent<IMorphicTabBar>>(MorphicTabBar);Usage
import { View, StyleSheet, Dimensions } from "react-native";import { GestureHandlerRootView } from "react-native-gesture-handler";import { StatusBar } from "expo-status-bar";import { useFonts } from "expo-font";import { useState } from "react";import { MorphicTabBar } from "@/components/molecules/morphing-tabbar";const { width: SCREEN_WIDTH } = Dimensions.get("window");export default function App() { const [fontLoaded] = useFonts({ SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"), HelveticaNowDisplay: require("@/assets/fonts/HelveticaNowDisplayMedium.ttf"), StretchPro: require("@/assets/fonts/StretchPro.otf"), }); const [currentIndex, setCurrentIndex] = useState<number>(0); const DATA = [ { id: "1", name: "MY DEAR MELANCHOLY", artist: "The Weeknd", year: "2018", image: "https://i.pinimg.com/1200x/18/e6/e8/18e6e8e2d2b8c5b4dd77a4ae705bf96a.jpg", }, { id: "2", name: "RANDOM ACCESS MEMORIES", artist: "Daft Punk", year: "2013", image: "https://i.pinimg.com/1200x/91/52/b2/9152b2dc174934279cda4509b0931434.jpg", }, { id: "3", name: "CURRENTS", artist: "Tame Impala", year: "2015", image: "https://i.pinimg.com/1200x/1e/38/7f/1e387f131098067f7a9be0bc68b0b6f2.jpg", }, { id: "4", name: "PLASTIC BEACH", artist: "Gorillaz", year: "2010", image: "https://i.pinimg.com/736x/43/e0/e0/43e0e0a542c0ccfbc5cf1b802bcf2d66.jpg", }, ]; const ITEMS: string[] = [ "https://i.pinimg.com/736x/74/d1/83/74d183cb89a6b10bd96203322e0d5512.jpg", "https://i.pinimg.com/736x/7a/52/bc/7a52bc56851dc1a16233308076658e47.jpg", "https://i.pinimg.com/1200x/31/46/be/3146be20950b9567fd38eb2a5bd00572.jpg", "https://i.pinimg.com/736x/cb/b2/b7/cbb2b7fc14c96fdb5916c82fa9fd555e.jpg", ]; return ( <GestureHandlerRootView style={styles.container}> <StatusBar style="inverted" /> <View style={styles.header}> {/* <View> <Text style={[styles.title, fontLoaded && { fontFamily: "StretchPro" }]} > GALLARY </Text> <Text style={[ styles.subtitle, fontLoaded && { fontFamily: "HelveticaNowDisplay" }, ]} > Explore your recent favorites. </Text> </View> <View style={styles.headerRight}> <SymbolView name="square.stack.3d.up.fill" size={20} tintColor="#fff" /> </View> */} <MorphicTabBar light={{ tabBackground: "#fff", activeText: "#000", inactiveText: "#353535", }} items={[ { keyPath: "/home", name: "Home", }, { keyPath: "/search", name: "Search", }, { keyPath: "/library", name: "Library", }, { keyPath: "/profile", name: "Profile", }, ]} textStyle={{ fontFamily: fontLoaded ? "HelveticaNowDisplay" : undefined, }} /> </View> </GestureHandlerRootView> );}const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#0a0a0a", }, header: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", paddingHorizontal: 40, paddingTop: 70, paddingBottom: 32, }, title: { fontSize: 28, color: "#fff", letterSpacing: 2, }, subtitle: { fontSize: 12, color: "#aaa", }, headerRight: { width: 40, height: 40, borderRadius: 20, backgroundColor: "#1a1a1a", justifyContent: "center", alignItems: "center", }, card: { width: "100%", height: 340, borderRadius: 24, overflow: "hidden", }, cardImage: { width: 300, height: "100%", resizeMode: "cover", }, cardGradient: { ...StyleSheet.absoluteFillObject, }, cardContent: { position: "absolute", bottom: 0, left: 0, right: 0, padding: 20, gap: 8, }, albumName: { fontSize: 16, color: "#fff", letterSpacing: 1, }, artistRow: { flexDirection: "row", alignItems: "center", gap: 6, }, artistText: { fontSize: 13, color: "rgba(255,255,255,0.7)", }, dot: { width: 3, height: 3, borderRadius: 1.5, backgroundColor: "rgba(255,255,255,0.4)", }, yearText: { fontSize: 13, color: "rgba(255,255,255,0.5)", }, footer: { paddingHorizontal: 24, marginTop: 32, gap: 20, }, nowPlaying: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", backgroundColor: "#141414", padding: 12, borderRadius: 16, }, nowPlayingLeft: { flexDirection: "row", alignItems: "center", gap: 12, flex: 1, }, nowPlayingImage: { width: 48, height: 48, borderRadius: 10, }, nowPlayingInfo: { flex: 1, gap: 2, }, nowPlayingTitle: { fontSize: 14, fontWeight: "600", color: "#fff", }, nowPlayingArtist: { fontSize: 12, color: "#666", }, nowPlayingControls: { width: 44, height: 44, borderRadius: 22, backgroundColor: "#fff", justifyContent: "center", alignItems: "center", }, dots: { flexDirection: "row", justifyContent: "center", alignItems: "center", gap: 6, }, dotIndicator: { width: 6, height: 6, borderRadius: 3, backgroundColor: "#333", }, dotIndicatorActive: { width: 20, backgroundColor: "#fff", },});Props
ITabBar
React Native Reanimated
React Native Skia
Expo Haptics
