Segmented Control

A gesture driven segmented control with a sliding active indicator

Last updated on

Edit on GitHub

Manual

Install the following dependencies:

npm install react-native-reanimated expo-blur react-native-worklets react native gesture handler expo-haptics

Copy and paste the following code into your project. component/organisms/segmented-control

import React, { memo, useCallback, useEffect, useRef } from "react";import {  Dimensions,  StyleSheet,  TouchableOpacity,  ViewStyle,} from "react-native";import Animated, {  useSharedValue,  useAnimatedStyle,  withSpring,  withTiming,  useAnimatedProps,  withSequence,  Easing,  runOnJS,} from "react-native-reanimated";import { SegmentedControlPresets, SHADOW } from "./presets";import type { ISegmentedControl } from "./types";import { GestureDetector, Gesture } from "react-native-gesture-handler";import { BlurView, type BlurViewProps } from "expo-blur";import { impactAsync, ImpactFeedbackStyle } from "expo-haptics";import { scheduleOnRN } from "react-native-worklets";const AnimatedBlurView =  Animated.createAnimatedComponent<Partial<BlurViewProps>>(BlurView);const width = Dimensions.get("screen").width - 32;const SegmentedControl: React.FC<ISegmentedControl> &  React.FunctionComponent<ISegmentedControl> = ({  children,  onChange,  currentIndex,  preset = "ios",  segmentedControlBackgroundColor,  activeSegmentBackgroundColor,  paddingVertical = 12,  dividerColor,  borderRadius = 8,  disableScaleEffect = false,}: ISegmentedControl):  | (React.ReactNode & React.JSX.Element & React.ReactElement)  | null => {  const theme = SegmentedControlPresets[preset];  const finalSegmentedControlBackgroundColor =    segmentedControlBackgroundColor ?? theme.segmentedControlBackgroundColor;  const finalActiveSegmentBackgroundColor =    activeSegmentBackgroundColor ?? theme.activeSegmentBackgroundColor;  const finalDividerColor = dividerColor ?? theme.dividerColor;  const childrenArray = React.Children.toArray(children);  const tabsCount = childrenArray.length;  const translateValue = (width - 4) / tabsCount;  const tabTranslate = useSharedValue<number>(currentIndex * translateValue);  const blurAmount = useSharedValue<number>(0);  const isDragging = useSharedValue<boolean>(false);  const dragStartIndex = useRef<number>(currentIndex);  const activeScale = useSharedValue(1);  const triggerBlur = useCallback(() => {    blurAmount.value = withSequence<number>(      withTiming<number>(10, {        duration: 400,        easing: Easing.inOut(Easing.ease),      }),      withTiming<number>(0, {        duration: 400,        easing: Easing.inOut(Easing.ease),      }),    );  }, []);  const triggerTapScale = useCallback(() => {    if (disableScaleEffect) return;    activeScale.value = withSequence<number>(      withTiming<number>(1.3, { duration: 350 }),      withSpring<number>(1, { stiffness: 10, damping: 5, mass: 0.8 }),    );  }, [disableScaleEffect]);  const memoizedTabPressCallback = useCallback(    (index: number) => {      onChange(index);      if (!isDragging.value) {        triggerBlur();        triggerTapScale();        impactAsync(ImpactFeedbackStyle.Medium);      }    },    [onChange, triggerBlur, triggerTapScale],  );  useEffect(() => {    tabTranslate.value = withSpring<number>(currentIndex * translateValue, {      stiffness: 80,      damping: 90,      mass: 1,    });  }, [currentIndex, translateValue]);  const animatedTabStyle = useAnimatedStyle<    Partial<Pick<ViewStyle, "transform">>  >(() => {    return {      transform: [        { translateX: tabTranslate.value },        { scale: activeScale.value },      ],    };  });  const animatedBlurViewProps = useAnimatedProps<    Required<Pick<BlurViewProps, "intensity">>  >(() => {    return {      intensity: blurAmount.value,    };  });  const panGesture = Gesture.Pan()    .minDistance(10)    .onStart(() => {      isDragging.value = true;      dragStartIndex.current = currentIndex;      if (disableScaleEffect) return;      activeScale.value = withSpring<number>(1.2, {        stiffness: 300,        damping: 15,      });      scheduleOnRN(impactAsync, ImpactFeedbackStyle.Medium);    })    .onUpdate((event) => {      const tabWidth = (width - 4) / tabsCount;      const rawIndex = Math.floor(event.x / tabWidth);      const newIndex = Math.max(0, Math.min(tabsCount - 1, rawIndex));      if (newIndex !== currentIndex && newIndex >= 0 && newIndex < tabsCount) {        scheduleOnRN(onChange, newIndex);        scheduleOnRN(impactAsync, ImpactFeedbackStyle.Rigid);      }    })    .onEnd(() => {      isDragging.value = false;      activeScale.value = withSpring<number>(1, {        stiffness: 200,        damping: 20,      });      if (currentIndex !== dragStartIndex.current) {        scheduleOnRN(triggerBlur);        scheduleOnRN(impactAsync, ImpactFeedbackStyle.Medium);      }    })    .onFinalize(() => {      isDragging.value = false;      activeScale.value = withSpring(1, { stiffness: 200, damping: 20 });    });  return (    <GestureDetector gesture={panGesture}>      <Animated.View        style={[          styles.segmentedControlWrapper,          {            backgroundColor: finalSegmentedControlBackgroundColor,            paddingVertical: paddingVertical,            borderRadius,          },        ]}      >        <Animated.View          style={[            {              ...StyleSheet.absoluteFillObject,              position: "absolute",              width: (width - 4) / tabsCount,              top: 0,              marginVertical: 2,              marginHorizontal: 2,              backgroundColor: finalActiveSegmentBackgroundColor,              borderRadius,              ...SHADOW,            },            animatedTabStyle,          ]}          pointerEvents="none"        />        {childrenArray.map<React.ReactNode>((child, index) => {          const showDivider = index < tabsCount - 1;          return (            <React.Fragment key={index}>              <TouchableOpacity                style={[styles.textWrapper]}                onPress={() => memoizedTabPressCallback(index)}                activeOpacity={0.7}              >                {child}              </TouchableOpacity>              {showDivider && (                <AnimatedDivider                  currentIndex={currentIndex}                  dividerIndex={index}                  color={finalDividerColor}                />              )}            </React.Fragment>          );        })}        <AnimatedBlurView          style={[            {              overflow: "hidden",              borderRadius,              ...StyleSheet.absoluteFillObject,            },          ]}          animatedProps={animatedBlurViewProps}          tint="default"          pointerEvents="none"        />      </Animated.View>    </GestureDetector>  );};const AnimatedDivider: React.FC<{  currentIndex: number;  dividerIndex: number;  color: string;}> = ({ currentIndex, dividerIndex, color }) => {  const opacity = useSharedValue(1);  useEffect(() => {    const shouldFadeOut =      dividerIndex === currentIndex || dividerIndex === currentIndex - 1;    opacity.value = withTiming(shouldFadeOut ? 0 : 1, {      duration: 200,    });  }, [currentIndex, dividerIndex]);  const animatedDividerStyle = useAnimatedStyle(() => {    return {      opacity: opacity.value,    };  });  return (    <Animated.View      style={[styles.divider, { backgroundColor: color }, animatedDividerStyle]}    />  );};const styles = StyleSheet.create({  segmentedControlWrapper: {    display: "flex",    flexDirection: "row",    alignItems: "center",    width: width,    marginVertical: 20,  },  textWrapper: {    flex: 1,    elevation: 9,    paddingHorizontal: 5,  },  divider: {    width: 1,    height: "60%",    alignSelf: "center",  },});export default memo(SegmentedControl);

Usage

import { StyleSheet, Text, View } from "react-native";import { GestureHandlerRootView } from "react-native-gesture-handler";import { StatusBar } from "expo-status-bar";import { useState } from "react";import SegmentedControl from "@/components/organisms/segmented-control";import { useFonts } from "expo-font";import { FontAwesome } from "@expo/vector-icons";export default function App() {  const [fontLoaded] = useFonts({    SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"),    HelveticaNowDisplay: require("@/assets/fonts/HelveticaNowDisplayMedium.ttf"),  });  const [index, setIndex] = useState(0);  return (    <GestureHandlerRootView style={styles.container}>      <StatusBar style="light" />      <View style={styles.card}>        <SegmentedControl          currentIndex={index}          onChange={setIndex}          paddingVertical={10}          borderRadius={200}          disableScaleEffect={false}        >          <Text            style={[              styles.tabText,              {                fontFamily: fontLoaded ? "SfProRounded" : undefined,              },            ]}          >            I          </Text>          <View            style={{              justifyContent: "center",              alignItems: "center",            }}          >            <FontAwesome name="heart" size={20} color="#ff4545" />          </View>          {/* <Text            style={[              styles.tabText,              {                fontFamily: fontLoaded ? "SfProRounded" : undefined,              },            ]}          >            Favorites          </Text> */}          <Text            style={[              styles.tabText,              {                fontFamily: fontLoaded ? "SfProRounded" : undefined,              },            ]}          >            Reacticx          </Text>        </SegmentedControl>      </View>    </GestureHandlerRootView>  );}const styles = StyleSheet.create({  container: {    flex: 1,    backgroundColor: "#000",    alignItems: "center",  },  card: {    width: "100%",    paddingHorizontal: 16,    paddingVertical: 24,    top: 80,  },  tabText: {    textAlign: "center",    fontSize: 14,    fontWeight: "500",    color: "#000000",  },  content: {    marginTop: 24,    alignItems: "center",  },  title: {    fontSize: 20,    fontWeight: "600",    color: "#fff",    marginBottom: 6,  },  subtitle: {    fontSize: 13,    color: "rgba(255,255,255,0.6)",    textAlign: "center",    maxWidth: 260,  },});

Props

React Native Reanimated
React Native Worklets
Expo Blur
React Native Gesture Handler
Expo Haptics