Mastering Route Groups (tabs), Tabs Navigator, @expo/vector-icons, Dynamic Badges, and Nested Stack + Tab Hierarchies.
Altamash, Bottom Tab navigation ko master karne ke liye ye 3 golden rules yaad rakho:
(tabs) (Invisible URL Folder):
Jab folder ka naam parentheses me hota hai jaise app/(tabs)/, toh URL me /(tabs) nahi dikhta! app/(tabs)/index.tsx seedha / route banta hai aur analytics.tsx seedha /analytics banta hai. Parentheses group banane ke liye hote hain, URL ko clean rakhne ke liye.
Stack me screens ek ke upar ek push/pop hoti hain (forward/backward history). Tabs me screens parallel hoti hain (Instagram ya Spotify ke bottom bar jaisi) — user instant ek tap se switch karta hai aur screen state memory me persist rehti hai!
Root app/_layout.tsx pure app ko ek Stack banata hai. Uske andar (tabs) ko as a screen load karte hain. Jab user /add-meal modal kholta hai, toh wo modal poore bottom tab bar ke upar smooth slide hota hai!
1. Organize routes cleanly using Expo Router Route Groups (tabs).
2. Build a modern floating dark-themed Tabs Navigator in app/(tabs)/_layout.tsx.
3. Integrate @expo/vector-icons (Ionicons) with smooth focused/unfocused state toggling.
4. Add Dynamic Tab Badges for real-time notification/item counters.
5. Complete Module 1 by upgrading MacroZone into a 3-Tab powerhouse: Today, Analytics, and Goals.
(tabs) & Multi-Layout ArchitectureIn Expo Router v6, wrapping a directory name in parentheses like (tabs) creates a Route Group. Route groups allow you to organize screens and assign dedicated layouts without adding extra segments to the web URL or deep-link path.
app/_layout.tsx → Root Stack NavigatorGlobal theme provider, Root Stack with modal overlay sheets (/add-meal) and detail pages (/meal/[id]).
app/(tabs)/_layout.tsx → Bottom Tabs NavigatorRenders the bottom navigation bar, icon toggles, active colors, and tab badges.
(tabs)/index.tsx/ (Today Tab)(tabs)/analytics.tsx/analytics (Charts Tab)(tabs)/goals.tsx/goals (Targets Tab)import { Stack } from "expo-router"; // 👈 Stack navigator import
export default function RootLayout() {
return (
// 👈 Root Stack manages global screen transitions & overlays
<Stack
screenOptions={{
headerStyle: { backgroundColor: "#020617" }, // 👈 Dark slate-950 header background
headerTintColor: "#38bdf8", // 👈 Sky-400 accent for titles & back buttons
contentStyle: { backgroundColor: "#020617" }, // 👈 OLED black canvas background
}}
>
{/* 👈 1. The entire (tabs) group is treated as one screen without a native stack header */}
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
{/* 👈 2. Modal Screen: slides from bottom over the entire tab bar */}
<Stack.Screen name="add-meal" options={{ title: "Log Meal", presentation: "modal" }} />
{/* 👈 3. Dynamic Detail Screen: card push transition */}
<Stack.Screen name="meal/[id]" options={{ title: "Meal Details" }} />
</Stack>
);
}
| Code Line | Kya Karta Hai? (Simple Explanation) |
|---|---|
<Stack.Screen name="(tabs)" options={{ headerShown: false }} /> |
Root stack ko batata hai ki (tabs) folder ek screen hai, aur uska default Stack header hide kar do taaki bottom tabs apna khud ka header control karein. |
presentation: "modal" |
Jab /add-meal open hoga, toh wo screen bottom se slide-up sheet ke roop me tab bar ke upar render hogi. |
contentStyle: { backgroundColor: "#020617" } |
Screen transitions ke dauran white flash na aaye isliye default dark canvas color set karta hai. |
@expo/vector-iconsTo build a high-polish bottom bar in app/(tabs)/_layout.tsx, we use the Tabs primitive from expo-router and icon components from @expo/vector-icons (e.g. Ionicons).
Renders filled icon stats-chart with vibrant emerald tint (#10b981).
Renders outline icon stats-chart-outline with muted slate tint (#64748b).
import { Tabs } from "expo-router"; // 👈 Bottom Tabs Navigator
import { Ionicons } from "@expo/vector-icons"; // 👈 High-res vector icons
export default function TabLayout() {
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: "#10b981", // 👈 Emerald-500 active tab highlight
tabBarInactiveTintColor: "#64748b", // 👈 Slate-500 muted inactive color
tabBarStyle: {
backgroundColor: "#0f172a", // 👈 Dark Slate-900 surface
borderTopColor: "#1e293b", // 👈 Subtle Slate-800 divider border
height: 62, // 👈 Comfortable touch height
paddingBottom: 8, // 👈 Safe inset spacing
paddingTop: 6,
},
headerStyle: { backgroundColor: "#020617" },// 👈 Dark top header
headerTintColor: "#f8fafc", // 👈 White header text
}}
>
{/* 👈 Tab 1: Today Screen */}
<Tabs.Screen
name="index"
options={{
title: "Today",
tabBarIcon: ({ color, focused }) => (
<Ionicons
name={focused ? "calendar" : "calendar-outline"}
size={22}
color={color}
/>
),
tabBarBadge: 3, // 👈 Dynamic notification badge count!
tabBarBadgeStyle: {
backgroundColor: "#10b981",
color: "#020617",
fontWeight: "bold",
},
}}
/>
{/* 👈 Tab 2: Analytics Screen */}
<Tabs.Screen
name="analytics"
options={{
title: "Analytics",
tabBarIcon: ({ color, focused }) => (
<Ionicons
name={focused ? "stats-chart" : "stats-chart-outline"}
size={22}
color={color}
/>
),
}}
/>
{/* 👈 Tab 3: Goals Screen */}
<Tabs.Screen
name="goals"
options={{
title: "Goals",
tabBarIcon: ({ color, focused }) => (
<Ionicons
name={focused ? "flag" : "flag-outline"}
size={22}
color={color}
/>
),
}}
/>
</Tabs>
);
}
| Code Line | Kya Karta Hai? (Simple Explanation) |
|---|---|
import { Tabs } from "expo-router" |
Native bottom tab navigation container component import karta hai. |
tabBarActiveTintColor: "#10b981" |
Jo tab currently open/active hai uske icon aur text ko vibrant emerald green color deta hai. |
tabBarStyle: { backgroundColor: "#0f172a" } |
Bottom bar ko modern dark slate background aur top border styling deta hai. |
tabBarIcon: ({ color, focused }) => (...) |
Tab ke active/inactive hone par filled (calendar) ya outline (calendar-outline) icon conditionally render karta hai. |
tabBarBadge: 3 |
Icon ke top-right corner par unread/logged items ka badge counter bubble render karta hai. |
Here is the visual cheat sheet for all Tailwind utility classes used in our new 3-tab screens:
h-2.5 w-full bg-slate-800 rounded-full overflow-hidden = Dark track container for progress bars.h-full bg-emerald-500 rounded-full = Colored progress indicator fill with smooth rounded edges.flex-row justify-between items-center = Aligns metric labels on the left and target numbers on the right.border-l-4 border-emerald-500 = Stylish 4px vertical accent highlight bar on cards.bg-slate-900 border border-slate-800 p-5 rounded-3xl = Standard elevated card surface container.gap-3 = 12px vertical/horizontal layout spacing (3 × 4px = 12px).text-emerald-400 font-extrabold text-2xl = Large high-contrast metric headline.active:scale-95 / active:bg-slate-800 = Micro-interaction touch response.Let's inspect how all screens in MacroZone are now organized cleanly across our 3 tabs:
import { useState } from "react";
import { View, Text, TextInput, Pressable, FlatList } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useRouter, Link } from "expo-router"; // 👈 Router hooks
import { Ionicons } from "@expo/vector-icons"; // 👈 Icons
export interface MealItem {
id: string;
name: string;
calories: number;
protein: number;
time: string;
}
export default function TodayScreen() {
const router = useRouter(); // 👈 Navigation controller
const [meals, setMeals] = useState<MealItem[]>([
{ id: "1", name: "Oats & Whey Protein", calories: 420, protein: 35, time: "8:30 AM" },
{ id: "2", name: "Grilled Chicken Salad", calories: 510, protein: 48, time: "1:15 PM" },
{ id: "3", name: "Boiled Eggs & Almonds", calories: 280, protein: 18, time: "5:00 PM" },
]);
const [name, setName] = useState("");
const [calories, setCalories] = useState("");
const [protein, setProtein] = useState("");
const totalCalories = meals.reduce((sum, m) => sum + m.calories, 0);
const totalProtein = meals.reduce((sum, m) => sum + m.protein, 0);
const calorieTarget = 2400;
const handleAddMeal = () => {
if (!name.trim() || !calories.trim()) return;
const newMeal: MealItem = {
id: Date.now().toString(),
name: name.trim(),
calories: parseInt(calories) || 0,
protein: parseInt(protein) || 0,
time: "Just now",
};
setMeals([newMeal, ...meals]);
setName("");
setCalories("");
setProtein("");
};
const handleDeleteMeal = (id: string) => {
setMeals(meals.filter((m) => m.id !== id));
};
return (
<SafeAreaView className="flex-1 bg-slate-950 px-5 pt-2">
{/* ── Top Header with Modal Action ── */}
<View className="bg-slate-900 border border-slate-800 rounded-3xl p-5 mb-4 shadow-2xl">
<View className="flex-row items-center justify-between mb-3">
<View>
<Text className="text-xs font-bold uppercase tracking-wider text-emerald-400">
Daily Fuel
</Text>
<Text className="text-2xl font-bold text-white">Today's Meals</Text>
</View>
{/* 👈 Opens Modal Sheet on Top of Tabs */}
<Link href="/add-meal" asChild>
<Pressable className="bg-emerald-600 active:bg-emerald-700 px-3.5 py-2 rounded-2xl flex-row items-center gap-1.5">
<Ionicons name="add-circle" size={16} color="#fff" />
<Text className="text-white text-xs font-bold">Modal</Text>
</Pressable>
</Link>
</View>
<View className="flex-row gap-3">
<View className="flex-1 bg-slate-950/80 border border-slate-800 p-3 rounded-2xl">
<Text className="text-slate-400 text-xs">Calories Eaten</Text>
<Text className="text-white text-xl font-bold mt-1">{totalCalories} <Text className="text-xs text-slate-500 font-normal">/ {calorieTarget}</Text></Text>
</View>
<View className="flex-1 bg-slate-950/80 border border-slate-800 p-3 rounded-2xl">
<Text className="text-slate-400 text-xs">Total Protein</Text>
<Text className="text-emerald-400 text-xl font-bold mt-1">{totalProtein}g</Text>
</View>
</View>
</View>
{/* ── Quick Add Form ── */}
<View className="bg-slate-900/90 border border-slate-800 p-4 rounded-2xl mb-4 gap-2.5">
<TextInput
value={name}
onChangeText={setName}
placeholder="Food Name (e.g. Greek Yogurt)"
placeholderTextColor="#64748b"
className="bg-slate-950 border border-slate-800 text-white px-3.5 py-2.5 rounded-xl text-sm"
/>
<View className="flex-row gap-2.5">
<TextInput
value={calories}
onChangeText={setCalories}
placeholder="Calories (kcal)"
placeholderTextColor="#64748b"
keyboardType="numeric"
className="flex-1 bg-slate-950 border border-slate-800 text-white px-3.5 py-2.5 rounded-xl text-sm"
/>
<TextInput
value={protein}
onChangeText={setProtein}
placeholder="Protein (g)"
placeholderTextColor="#64748b"
keyboardType="numeric"
className="flex-1 bg-slate-950 border border-slate-800 text-white px-3.5 py-2.5 rounded-xl text-sm"
/>
</View>
<Pressable
onPress={handleAddMeal}
className="bg-emerald-600 active:bg-emerald-700 py-3 rounded-xl items-center mt-1"
>
<Text className="text-white font-semibold text-sm">+ Quick Log Meal</Text>
</Pressable>
</View>
{/* ── Meal List ── */}
<FlatList
data={meals}
keyExtractor={(item) => item.id}
showsVerticalScrollIndicator={false}
renderItem={({ item }) => (
<Pressable
onPress={() =>
router.push({
pathname: "/meal/[id]",
params: {
id: item.id,
name: item.name,
calories: item.calories.toString(),
protein: item.protein.toString(),
},
})
}
className="bg-slate-900 border border-slate-800/80 p-3.5 rounded-2xl flex-row justify-between items-center mb-2.5 active:bg-slate-800"
>
<View className="flex-1 mr-2">
<Text className="text-white font-medium text-sm">{item.name}</Text>
<Text className="text-slate-400 text-xs mt-0.5">{item.time} • {item.protein}g protein</Text>
</View>
<View className="flex-row items-center gap-2">
<View className="bg-emerald-500/10 border border-emerald-500/20 px-2.5 py-1 rounded-xl">
<Text className="text-emerald-300 font-bold text-xs">{item.calories} kcal</Text>
</View>
<Pressable
onPress={(e) => {
e.stopPropagation?.();
handleDeleteMeal(item.id);
}}
className="w-7 h-7 rounded-lg bg-red-500/10 border border-red-500/20 items-center justify-center active:bg-red-500/20"
>
<Text className="text-red-400 text-xs font-bold">✕</Text>
</Pressable>
</View>
</Pressable>
)}
/>
</SafeAreaView>
);
}
import { View, Text, ScrollView } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { Ionicons } from "@expo/vector-icons";
export default function AnalyticsScreen() {
const caloriesConsumed = 1210;
const calorieGoal = 2400;
const proteinCurrent = 101;
const proteinGoal = 160;
const carbsCurrent = 115;
const carbsGoal = 220;
const fatsCurrent = 38;
const fatsGoal = 70;
return (
<SafeAreaView className="flex-1 bg-slate-950 px-5 pt-2">
<ScrollView showsVerticalScrollIndicator={false}>
{/* Header Title */}
<View className="mb-4">
<Text className="text-xs font-bold uppercase tracking-wider text-sky-400">
Performance & Nutrition
</Text>
<Text className="text-2xl font-bold text-white">Daily Analytics</Text>
</View>
{/* Calorie Goal Ring / Bar Card */}
<View className="bg-slate-900 border border-slate-800 rounded-3xl p-5 mb-4 shadow-2xl">
<View className="flex-row justify-between items-center mb-2">
<Text className="text-slate-300 font-semibold">Energy Balance</Text>
<Text className="text-sky-400 font-bold text-sm">
{Math.round((caloriesConsumed / calorieGoal) * 100)}% of goal
</Text>
</View>
<View className="h-3 w-full bg-slate-950 rounded-full overflow-hidden mb-3 border border-slate-800">
<View
className="h-full bg-sky-500 rounded-full"
style={{ width: `${Math.min(100, (caloriesConsumed / calorieGoal) * 100)}%` }}
/>
</View>
<View className="flex-row justify-between text-xs">
<Text className="text-slate-400 text-xs">{caloriesConsumed} kcal eaten</Text>
<Text className="text-slate-400 text-xs">{calorieGoal - caloriesConsumed} kcal remaining</Text>
</View>
</View>
{/* Macro Nutrient Breakdown Cards */}
<Text className="text-sm font-bold text-slate-300 uppercase tracking-wider mb-3">
Macronutrient Split
</Text>
{/* Protein Progress */}
<View className="bg-slate-900 border border-slate-800 rounded-2xl p-4 mb-3 border-l-4 border-l-emerald-500">
<View className="flex-row justify-between items-center mb-1.5">
<Text className="text-white font-bold text-sm">🍗 Protein</Text>
<Text className="text-emerald-400 font-bold text-sm">{proteinCurrent}g / {proteinGoal}g</Text>
</View>
<View className="h-2 w-full bg-slate-950 rounded-full overflow-hidden">
<View
className="h-full bg-emerald-500 rounded-full"
style={{ width: `${(proteinCurrent / proteinGoal) * 100}%` }}
/>
</View>
</View>
{/* Carbs Progress */}
<View className="bg-slate-900 border border-slate-800 rounded-2xl p-4 mb-3 border-l-4 border-l-amber-500">
<View className="flex-row justify-between items-center mb-1.5">
<Text className="text-white font-bold text-sm">🌾 Carbohydrates</Text>
<Text className="text-amber-400 font-bold text-sm">{carbsCurrent}g / {carbsGoal}g</Text>
</View>
<View className="h-2 w-full bg-slate-950 rounded-full overflow-hidden">
<View
className="h-full bg-amber-500 rounded-full"
style={{ width: `${(carbsCurrent / carbsGoal) * 100}%` }}
/>
</View>
</View>
{/* Fats Progress */}
<View className="bg-slate-900 border border-slate-800 rounded-2xl p-4 mb-4 border-l-4 border-l-rose-500">
<View className="flex-row justify-between items-center mb-1.5">
<Text className="text-white font-bold text-sm">🥑 Healthy Fats</Text>
<Text className="text-rose-400 font-bold text-sm">{fatsCurrent}g / {fatsGoal}g</Text>
</View>
<View className="h-2 w-full bg-slate-950 rounded-full overflow-hidden">
<View
className="h-full bg-rose-500 rounded-full"
style={{ width: `${(fatsCurrent / fatsGoal) * 100}%` }}
/>
</View>
</View>
{/* Streak Banner */}
<View className="bg-slate-900 border border-slate-800 p-4 rounded-2xl flex-row items-center gap-3 mb-6">
<View className="w-10 h-10 rounded-xl bg-amber-500/10 border border-amber-500/20 items-center justify-center">
<Ionicons name="flame" size={22} color="#f59e0b" />
</View>
<View>
<Text className="text-white font-bold text-sm">5-Day Consistency Streak! 🔥</Text>
<Text className="text-slate-400 text-xs">You've hit your protein goal 5 days in a row.</Text>
</View>
</View>
</ScrollView>
</SafeAreaView>
);
}
import { useState } from "react";
import { View, Text, TextInput, Pressable, ScrollView, Alert } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { Ionicons } from "@expo/vector-icons";
export default function GoalsScreen() {
const [calorieTarget, setCalorieTarget] = useState("2400");
const [proteinTarget, setProteinTarget] = useState("160");
const [carbsTarget, setCarbsTarget] = useState("220");
const [fatsTarget, setFatsTarget] = useState("70");
const [activePlan, setActivePlan] = useState<"cut" | "maintain" | "bulk">("maintain");
const handleSave = () => {
Alert.alert("Goals Updated! 🎯", "Your daily macro targets have been successfully saved.");
};
const handlePreset = (plan: "cut" | "maintain" | "bulk") => {
setActivePlan(plan);
if (plan === "cut") {
setCalorieTarget("2000");
setProteinTarget("180");
setCarbsTarget("160");
setFatsTarget("55");
} else if (plan === "maintain") {
setCalorieTarget("2400");
setProteinTarget("160");
setCarbsTarget("220");
setFatsTarget("70");
} else if (plan === "bulk") {
setCalorieTarget("2850");
setProteinTarget("170");
setCarbsTarget("320");
setFatsTarget("85");
}
};
return (
<SafeAreaView className="flex-1 bg-slate-950 px-5 pt-2">
<ScrollView showsVerticalScrollIndicator={false}>
{/* Header */}
<View className="mb-4">
<Text className="text-xs font-bold uppercase tracking-wider text-purple-400">
Preferences & Targets
</Text>
<Text className="text-2xl font-bold text-white">Nutritional Goals</Text>
</View>
{/* Preset Selector */}
<View className="flex-row gap-2 mb-5">
{(["cut", "maintain", "bulk"] as const).map((plan) => (
<Pressable
key={plan}
onPress={() => handlePreset(plan)}
className={`flex-1 py-2.5 rounded-xl items-center border ${
activePlan === plan
? "bg-purple-600 border-purple-500"
: "bg-slate-900 border-slate-800"
}`}
>
<Text
className={`text-xs font-bold uppercase tracking-wider ${
activePlan === plan ? "text-white" : "text-slate-400"
}`}
>
{plan === "cut" ? "🔥 Cut" : plan === "maintain" ? "⚖️ Maintain" : "💪 Bulk"}
</Text>
</Pressable>
))}
</View>
{/* Input Target Cards */}
<View className="bg-slate-900 border border-slate-800 rounded-3xl p-5 mb-5 gap-4">
<View>
<Text className="text-slate-400 text-xs font-bold uppercase mb-1">Daily Calorie Target (kcal)</Text>
<TextInput
value={calorieTarget}
onChangeText={setCalorieTarget}
keyboardType="numeric"
className="bg-slate-950 border border-slate-800 text-white px-4 py-3 rounded-xl text-base font-bold"
/>
</View>
<View>
<Text className="text-slate-400 text-xs font-bold uppercase mb-1">Protein Target (grams)</Text>
<TextInput
value={proteinTarget}
onChangeText={setProteinTarget}
keyboardType="numeric"
className="bg-slate-950 border border-slate-800 text-white px-4 py-3 rounded-xl text-base font-bold"
/>
</View>
<View className="flex-row gap-3">
<View className="flex-1">
<Text className="text-slate-400 text-xs font-bold uppercase mb-1">Carbs (g)</Text>
<TextInput
value={carbsTarget}
onChangeText={setCarbsTarget}
keyboardType="numeric"
className="bg-slate-950 border border-slate-800 text-white px-4 py-3 rounded-xl text-base font-bold"
/>
</View>
<View className="flex-1">
<Text className="text-slate-400 text-xs font-bold uppercase mb-1">Fats (g)</Text>
<TextInput
value={fatsTarget}
onChangeText={setFatsTarget}
keyboardType="numeric"
className="bg-slate-950 border border-slate-800 text-white px-4 py-3 rounded-xl text-base font-bold"
/>
</View>
</View>
</View>
<Pressable
onPress={handleSave}
className="bg-purple-600 active:bg-purple-700 py-3.5 rounded-2xl items-center flex-row justify-center gap-2 mb-8"
>
<Ionicons name="checkmark-done-circle" size={18} color="#fff" />
<Text className="text-white font-bold text-sm">Save Nutritional Goals</Text>
</Pressable>
</ScrollView>
</SafeAreaView>
);
}
Test your mental model on route groups, tab bar options, and vector icon switching with these 4 interactive challenges:
Let's verify our completed Module 1 architecture inside your live mobile app:
/meal/[id] continues working seamlessly.💬 Ask Your Teacher: What questions do you have about route groups (tabs), custom tab bars, or vector icon styling? Let's discuss in the chat!