Morphing Tab Bar

Tabs transition fluidly as selection changes

Last updated on

Edit on GitHub

Manual

Install the following dependencies:

npm install react-native-reanimated @shopify/react-native-skia expo-haptics

Copy 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