Animated tabs with a sliding indicator in React Native

By the LumioUI team · Updated

To animate tabs in React Native, measure each tab with onLayout. Then drive one indicator's position and width from Reanimated shared values, and spring them to the selected tab. The label colours and the panel below should follow the same selection. The LumioUI Tabs component does this for you, as a pill or an underline.

Live demo of the LumioUI Tabs component. It's interactive, so try it.

How a sliding tab indicator works

  1. Measure. Each tab reports its x and width through onLayout. Store them in a ref, because re-rendering on every measurement is wasted work.
  2. Move one indicator. A single absolutely positioned view sits behind the labels. Animate its translateX and width with a spring, so it glides and stretches between tabs of different widths.
  3. Swap the panel. Fade the old panel out as the new one fades in. Keep the exit shorter than the entrance so the switch feels quick.
  4. Accessibility. Tabs need accessibilityRole="tab" and selected state, so screen readers announce "Month, tab, selected".
TSX SimpleTabs.tsx
import * as React from "react";
import { LayoutChangeEvent, Pressable, Text, View } from "react-native";
import Animated, { useAnimatedStyle, useSharedValue, withSpring } from "react-native-reanimated";

export function SimpleTabs({ tabs, value, onChange }) {
  const x = useSharedValue(0);
  const w = useSharedValue(0);
  const layouts = React.useRef<Record<string, { x: number; width: number }>>({});

  const moveTo = (key: string) => {
    const l = layouts.current[key];
    if (!l) return;
    x.value = withSpring(l.x, { damping: 20, stiffness: 260 });
    w.value = withSpring(l.width, { damping: 20, stiffness: 260 });
  };
  React.useEffect(() => moveTo(value), [value]);

  const indicator = useAnimatedStyle(() => ({ width: w.value, transform: [{ translateX: x.value }] }));

  return (
    <View style={{ flexDirection: "row" }}>
      <Animated.View style={[{ position: "absolute", top: 0, bottom: 0, borderRadius: 999, backgroundColor: "#111" }, indicator]} />
      {tabs.map((t) => (
        <Pressable
          key={t}
          accessibilityRole="tab"
          accessibilityState={{ selected: t === value }}
          onLayout={(e: LayoutChangeEvent) => {
            layouts.current[t] = e.nativeEvent.layout;
            if (t === value) moveTo(t);
          }}
          onPress={() => onChange(t)}
          style={{ paddingHorizontal: 16, paddingVertical: 8 }}
        >
          <Text style={{ color: t === value ? "#fff" : "#666" }}>{t}</Text>
        </Pressable>
      ))}
    </View>
  );
}

From here you'd still add reduced-motion support (jump instead of glide), label colours that change with the indicator, the panel transition, and haptics on change.

The quick way: LumioUI Tabs

The Tabs component's indicator glides to the chosen tab, the label colour follows it, and the new panel fades up as the old one fades away. Choose variant="pill" or "line". It's free and runs in Expo Go.

TSX Spending.tsx
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs/tabs";
import { Text } from "@/components/ui/text/text";

export function Spending() {
  return (
    <Tabs defaultValue="week" variant="pill">
      <TabsList>
        <TabsTrigger value="week">Week</TabsTrigger>
        <TabsTrigger value="month">Month</TabsTrigger>
        <TabsTrigger value="year">Year</TabsTrigger>
      </TabsList>
      <TabsContent value="week"><Text>$214 this week</Text></TabsContent>
      <TabsContent value="month"><Text>$1,032 this month</Text></TabsContent>
      <TabsContent value="year"><Text>$11,870 this year</Text></TabsContent>
    </Tabs>
  );
}
  • value and onValueChange make it controlled. Use defaultValue to let it manage itself.
  • TabsTrigger takes an icon and disabled.
  • For a bottom navigation bar instead of in-page tabs, see Dock (Pro). For two or three options, see Segmented Control.

Add it to your app

LumioUI's CLI copies the component's source into your project, so it's yours to read and change. Run init once per project:

npx @lumioui/cli init
npx @lumioui/cli add tabs

It works in Expo (SDK 54+) and bare React Native, with Reanimated 4 and NativeWind. See the installation guide and the full Tabs API.

More React Native guides