StealThis .dev

React Native Bottom Tabs

A bottom tab navigator for React Native with animated indicator, badge support, icon + label layout, and haptic-ready tab switching.

react-native typescript
Targets: React Native

Expo Snack

Code

import React, { useState, useRef, useEffect } from "react";
import {
  View,
  Text,
  TouchableOpacity,
  Animated,
  StyleSheet,
  Dimensions,
  type ReactNode,
} from "react-native";

/* ------------------------------------------------------------------ */
/*  Types                                                              */
/* ------------------------------------------------------------------ */

interface Tab {
  key: string;
  label: string;
  icon: string;
  badge?: string | number;
  screen: ReactNode;
}

interface BottomTabsProps {
  tabs: Tab[];
  initialTab?: string;
}

/* ------------------------------------------------------------------ */
/*  BottomTabs                                                         */
/* ------------------------------------------------------------------ */

function BottomTabs({ tabs, initialTab }: BottomTabsProps) {
  const [activeKey, setActiveKey] = useState(initialTab ?? tabs[0]?.key ?? "");
  const tabWidth = Dimensions.get("window").width / tabs.length;

  // Sliding indicator
  const activeIndex = tabs.findIndex((t) => t.key === activeKey);
  const indicatorX = useRef(new Animated.Value(activeIndex * tabWidth)).current;

  // Per-tab icon scale
  const scales = useRef(
    tabs.map((_, i) => new Animated.Value(i === activeIndex ? 1.15 : 1))
  ).current;

  useEffect(() => {
    const idx = tabs.findIndex((t) => t.key === activeKey);
    if (idx === -1) return;

    Animated.spring(indicatorX, {
      toValue: idx * tabWidth,
      useNativeDriver: true,
      friction: 6,
      tension: 100,
    }).start();

    scales.forEach((scale, i) => {
      Animated.spring(scale, {
        toValue: i === idx ? 1.15 : 1,
        useNativeDriver: true,
        friction: 5,
        tension: 120,
      }).start();
    });
  }, [activeKey]);

  const activeScreen = tabs.find((t) => t.key === activeKey)?.screen ?? null;

  return (
    <View style={styles.container}>
      {/* Screen area */}
      <View style={styles.screenArea}>{activeScreen}</View>

      {/* Tab bar */}
      <View style={styles.tabBar}>
        {/* Animated indicator */}
        <Animated.View
          style={[
            styles.indicator,
            {
              width: tabWidth * 0.5,
              transform: [{ translateX: Animated.add(indicatorX, tabWidth * 0.25) }],
            },
          ]}
        />

        {tabs.map((tab, i) => {
          const isActive = tab.key === activeKey;
          return (
            <TouchableOpacity
              key={tab.key}
              activeOpacity={0.7}
              style={[styles.tab, { width: tabWidth }]}
              onPress={() => setActiveKey(tab.key)}
            >
              <View style={styles.iconWrapper}>
                <Animated.Text style={[styles.icon, { transform: [{ scale: scales[i] }] }]}>
                  {tab.icon}
                </Animated.Text>

                {/* Badge */}
                {tab.badge != null && (
                  <View style={styles.badge}>
                    <Text style={styles.badgeText}>
                      {typeof tab.badge === "number" && tab.badge > 99 ? "99+" : String(tab.badge)}
                    </Text>
                  </View>
                )}
              </View>

              <Text style={[styles.label, isActive && styles.labelActive]}>{tab.label}</Text>
            </TouchableOpacity>
          );
        })}
      </View>
    </View>
  );
}

/* ------------------------------------------------------------------ */
/*  Styles                                                             */
/* ------------------------------------------------------------------ */

const BOTTOM_SAFE = 24;
const TAB_BAR_HEIGHT = 60;

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: "#0f172a",
  },
  screenArea: {
    flex: 1,
  },
  tabBar: {
    flexDirection: "row",
    height: TAB_BAR_HEIGHT + BOTTOM_SAFE,
    paddingBottom: BOTTOM_SAFE,
    backgroundColor: "#1e293b",
    borderTopWidth: StyleSheet.hairlineWidth,
    borderTopColor: "rgba(255,255,255,0.08)",
  },
  indicator: {
    position: "absolute",
    top: 0,
    height: 3,
    borderRadius: 2,
    backgroundColor: "#818cf8",
  },
  tab: {
    alignItems: "center",
    justifyContent: "center",
    paddingTop: 8,
  },
  iconWrapper: {
    position: "relative",
  },
  icon: {
    fontSize: 22,
  },
  badge: {
    position: "absolute",
    top: -4,
    right: -10,
    minWidth: 18,
    height: 18,
    borderRadius: 9,
    backgroundColor: "#ef4444",
    alignItems: "center",
    justifyContent: "center",
    paddingHorizontal: 4,
  },
  badgeText: {
    color: "#fff",
    fontSize: 10,
    fontWeight: "700",
  },
  label: {
    fontSize: 11,
    color: "rgba(255,255,255,0.45)",
    marginTop: 2,
  },
  labelActive: {
    color: "#818cf8",
    fontWeight: "600",
  },
});

/* ------------------------------------------------------------------ */
/*  Demo screen helper                                                 */
/* ------------------------------------------------------------------ */

function DemoScreen({ title, emoji }: { title: string; emoji: string }) {
  return (
    <View style={demoStyles.screen}>
      <Text style={demoStyles.emoji}>{emoji}</Text>
      <Text style={demoStyles.title}>{title}</Text>
    </View>
  );
}

const demoStyles = StyleSheet.create({
  screen: {
    flex: 1,
    alignItems: "center",
    justifyContent: "center",
    backgroundColor: "#0f172a",
  },
  emoji: {
    fontSize: 48,
    marginBottom: 12,
  },
  title: {
    fontSize: 20,
    fontWeight: "600",
    color: "#e2e8f0",
  },
});

/* ------------------------------------------------------------------ */
/*  App (demo)                                                         */
/* ------------------------------------------------------------------ */

export default function App() {
  return (
    <BottomTabs
      tabs={[
        {
          key: "home",
          label: "Home",
          icon: "🏠",
          screen: <DemoScreen title="Home" emoji="🏠" />,
        },
        {
          key: "search",
          label: "Search",
          icon: "🔍",
          screen: <DemoScreen title="Search" emoji="🔍" />,
        },
        {
          key: "notifications",
          label: "Notifications",
          icon: "🔔",
          badge: "3",
          screen: <DemoScreen title="Notifications" emoji="🔔" />,
        },
        {
          key: "profile",
          label: "Profile",
          icon: "👤",
          screen: <DemoScreen title="Profile" emoji="👤" />,
        },
      ]}
    />
  );
}

React Native Bottom Tabs

A fully self-contained bottom tab navigator built with pure React Native. No third-party navigation library required — just drop it in, pass your tabs, and go. The component features a spring-animated sliding indicator, subtle icon scaling on the active tab, and optional badge counts on any tab icon.

Props

PropTypeDescription
tabsTab[]Array of tab definitions (see below)
initialTabstringKey of the tab to show initially (defaults to first tab)

Each item in the tabs array:

FieldTypeDescription
keystringUnique identifier for the tab
labelstringText label shown below the icon
iconstringEmoji or short text used as the tab icon
badgestring | numberOptional badge count or dot displayed on the icon
screenReactNodeThe content rendered when this tab is active

Usage

<BottomTabs
  tabs={[
    { key: "home", label: "Home", icon: "🏠", screen: <HomeScreen /> },
    { key: "search", label: "Search", icon: "🔍", screen: <SearchScreen /> },
    { key: "alerts", label: "Alerts", icon: "🔔", badge: 3, screen: <AlertsScreen /> },
    { key: "profile", label: "Profile", icon: "👤", screen: <ProfileScreen /> },
  ]}
/>

How it works

  1. Tab state is managed with a simple useState holding the active tab key. Switching tabs updates this key, which conditionally renders the matching screen node.
  2. Sliding indicator — An Animated.Value tracks the horizontal offset of the active tab. When the user taps a new tab, Animated.spring drives translateX to the new position, producing a smooth elastic slide beneath the tab bar.
  3. Icon scale — Each tab icon wraps in its own Animated.Value for scale. The active tab springs to 1.15 while inactive tabs settle back to 1.0, giving a subtle but satisfying pop.
  4. Badges — If a tab has a badge prop, a small red circle is absolutely positioned on the top-right of the icon area. Numbers render inside; falsy values hide the badge entirely.
  5. Safe area — The tab bar applies bottom padding via a fixed value (adjustable) to stay above the home indicator on notched devices, keeping it usable without pulling in react-native-safe-area-context.