Irony is wasted on the stupid, Oscar Wilde
To err is human, to blame it on someone else is even more human, Jacob’s Law

Welcome back! In Part 1 we set up our prayer companion app with a Stack navigator, learned how Expo Router turns files into screens, and got comfortable with the _layout.tsx pattern. Now it’s time to add a familiar mobile pattern – the bottom tab bar.
By the end of this article you will understand how Expo Router’s file‑based system handles tab navigation, why a folder named (tabs) is magic, and how to build a beautiful, dark‑themed five‑tab bar with zero route configuration. Let’s dive in.
How Expo Router maps folder names to navigation groups, and why parentheses matter.
The special role of the (tabs) folder in creating a native bottom tab bar.
How a _layout.tsx inside a group wraps every screen inside it, acting as a shared “template”.
How to configure tab icons, labels, and active/inactive colours using <Tabs.Screen>.
The crucial difference between a Stack (drill‑down navigation) and a Tabs (flat, always‑visible navigation).
Why tab screens stay mounted in memory and what that means for your app’s performance.
Five tabs at the bottom of the screen, each with its own icon, label, and dark‑themed screen:
📖 Verse | 🙏 Videos | 🌐 Online | ✝ Prayers | 📔 Diary
If you’ve already used a static site generator like Hugo, you already know the idea: Your file structure IS your navigation.
content/calculus/firstorder.md → /calculus/firstorder/
Expo Router works the same way, it scans the app/ directory and automatically creates a route for every .tsx file. You never write a route map.
app/index.tsx → the home screen (route: "/")
app/(tabs)/verse.tsx → the Verse tab (route: "/verse")
app/(tabs)/diary.tsx → the Diary tab (route: "/diary")
app/settings.tsx → settings screen (route: "/settings")
When a folder name is wrapped in parentheses, like (tabs), Expo Router treats it as a navigation group:
The folder does not appear in the URL, app/(tabs)/verse.tsx becomes /verse, not /tabs/verse.
The folder is a navigation group. The purpose is to apply a shared layout to all screens inside it.
Think of (tabs) as an invisible organisational wrapper. It says: “All these screens belong together, and they share a tab bar.”
Any folder can contain an _layout.tsx file. This file defines the UI that surrounds every screen inside that folder. In Hugo terms, it’s like a section template that applies to all content in that directory.
For (tabs), that layout will be a bottom tab navigator. Every file we place inside (tabs) (like verse.tsx, diary.tsx) automatically becomes a tab.
When a user taps a tab, Expo Router renders a hierarchy:
RootLayout, _layout.tsx at app/ defines the tab bar (icons, colours, labels)
└── TabLayout _layout.tsx at app/(tabs)/
└── The tab’s screen component, e.g., verse.tsx, rendered inside the tab bar
└── The tab’s Video component, e.g., videos.tsx, rendered inside the tab bar
Stack navigator (from Part 1): one screen pushes another on top, with a back button. Perfect for drill‑down flows like Home → Settings → About.
Tab navigator: all screens are siblings, accessible from a persistent bottom bar. Tabs are always visible and switching between them is instant because React Native keeps all tab screens mounted in memory after the first visit.
In our app, the tab bar is the primary navigation. Later we will nest stacks inside individual tabs – but for now, we are building a flat, five‑tab layout.
Open your terminal in the project root and run:
cd prayer-companion
# Create the (tabs) folder
# The parentheses must be part of the actual folder name.
mkdir -p "app/(tabs)"
Your app/ folder should now look like:
app/
├── _layout.tsx ← root layout
├── index.tsx ← home screen
└── (tabs)/ ← NEW: tab navigation group
└── (empty for now)
We haven’t created any screen files yet – that happens later. First we need to define the tab bar layout itself.
app/(tabs)/_layout.tsxThis file is the brain of our tab navigation. It tells Expo Router:
Create the file app/(tabs)/_layout.tsx and paste the entire code below. We’ll then walk through each section in detail.
// app/(tabs)/_layout.tsx
//
// WHAT THIS FILE DOES
// ───────────────────
// This is the layout for the (tabs) navigation group.
// It renders a bottom tab bar with one tab for each screen file in this folder.
//
// Expo Router reads this file and automatically creates tabs for:
// verse.tsx → Verse tab
// videos.tsx → Videos tab
// online.tsx → Online tab
// prayers.tsx → Prayers tab
// diary.tsx → Diary tab
//
// HOW TAB NAVIGATION WORKS
// ────────────────────────
// Tabs from expo-router is the navigator component that creates (or renders) the bottom tab bar.
// Platform helps us adjust styles between iOS and Android.
// Each Tabs.Screen configures ONE tab:
// name: must exactly match the filename of the screen inside (tabs) without the .tsx extension.
// For example, name="verse" will look for verse.tsx.
// This is how Expo Router maps the screen component to this tab definition.
// options, an object where we configure:
// title – the label shown on the tab bar (under the icon).
// headerTitle – the text shown in the header when this tab is active.
// We prepend an emoji to give it personality.
// tabBarIcon – a function that returns a React element for the icon. It receives an object containing color,
// which is automatically set to the active or inactive tint colour depending on whether the tab is focused.
// This is the magic that allows one icon component to react to focus.
// The user taps a tab → that screen's component is mounted (or brought to the foreground if already mounted).
// React Native keeps all tab screens mounted in memory once visited, so switching back is instant.
//
// ICONS
// ─────
// Notice we use a custom TabIcon component to render the emoji. We’ll discuss that next.
import { Tabs } from "expo-router";
import { Platform } from "react-native";
// ── Colour palette (same constants as Python app) ──────────────────────────
// A set of constants placed at the top so they can be reused everywhere.
// The same colours will later be centralised in a src/theme.ts file,
// but for now defining them here keeps things simple.
const COLOURS = {
bg: "#1a1f2e", // dark navy — screen background
tabBar: "#222840", // slightly lighter — tab bar background
tabBorder: "#2d3550", // subtle top border on the tab bar
activeTab: "#c9a84c", // gold — active tab icon and label
inactiveTab: "#6b7280", // muted grey — inactive tab icon and label
headerBg: "#1a1f2e", // header background (same as screen)
headerText: "#dde4f0", // header title text
};
export default function TabLayout() {
return (
<Tabs
screenOptions={{
// ── Tab bar appearance ──────────────────────────────────────────────
tabBarStyle: {
backgroundColor: COLOURS.tabBar,
borderTopColor: COLOURS.tabBorder,
borderTopWidth: 1,
// paddingBottom ensures the tab labels are not hidden behind
// the home indicator on iPhones with no home button
paddingBottom: Platform.OS === "ios" ? 20 : 8,
paddingTop: 8,
height: Platform.OS === "ios" ? 80 : 60,
},
// ── Active tab: gold icon + gold label ──────────────────────────────
tabBarActiveTintColor: COLOURS.activeTab,
// ── Inactive tab: muted grey ─────────────────────────────────────────
tabBarInactiveTintColor: COLOURS.inactiveTab,
// ── Label style (the text under each icon) ───────────────────────────
tabBarLabelStyle: {
fontSize: 11,
fontWeight: "600",
marginTop: 2,
},
// ── Header (the top bar showing the screen title) ────────────────────
// Every tab screen automatically gets a header.
// We style it to match the dark theme.
headerStyle: {
backgroundColor: COLOURS.headerBg,
// Remove the shadow line below the header on iOS
shadowOpacity: 0,
// Remove the shadow line on Android
elevation: 0,
},
headerTitleStyle: {
color: COLOURS.headerText,
fontWeight: "bold",
fontSize: 18,
},
// No back button tint needed for tabs
headerTintColor: COLOURS.headerText,
}}
>
{/*
Each <Tabs.Screen> maps to one file in this folder.
The `name` prop MUST exactly match the filename (without .tsx).
The `options` prop customises how that tab appears.
tabBarIcon receives a function that returns the icon element.
{ color } is destructured from the argument — it is either
activeTab colour or inactiveTab colour, automatically chosen by
the tab bar based on whether this tab is selected.
*/}
{/* ── Tab 1: Verse ─────────────────────────────────────────────────── */}
<Tabs.Screen
name="verse"
options={{
title: "Verse", // tab label
headerTitle: "📖 Scripture", // header bar title
tabBarIcon: ({ color }) => (
// We render the emoji at a fixed size inside a Text component.
<TabIcon emoji="📖" color={color} />
),
}}
/>
{/* ── Tab 2: Videos ────────────────────────────────────────────────── */}
<Tabs.Screen
name="videos"
options={{
title: "Videos",
headerTitle: "🙏 Prayer Videos",
tabBarIcon: ({ color }) => (
<TabIcon emoji="🙏" color={color} />
),
}}
/>
{/* ── Tab 3: Online ────────────────────────────────────────────────── */}
<Tabs.Screen
name="online"
options={{
title: "Online",
headerTitle: "🌐 Prayer Sites",
tabBarIcon: ({ color }) => (
<TabIcon emoji="🌐" color={color} />
),
}}
/>
{/* ── Tab 4: Prayers ───────────────────────────────────────────────── */}
<Tabs.Screen
name="prayers"
options={{
title: "Prayers",
headerTitle: "✝ My Prayers",
tabBarIcon: ({ color }) => (
<TabIcon emoji="✝" color={color} />
),
}}
/>
{/* ── Tab 5: Diary ─────────────────────────────────────────────────── */}
<Tabs.Screen
name="diary"
options={{
title: "Diary",
headerTitle: "📔 Prayer Diary",
tabBarIcon: ({ color }) => (
<TabIcon emoji="📔" color={color} />
),
}}
/>
</Tabs>
);
}
// ── TabIcon helper component ─────────────────────────────────────────────────
//
// A tiny reusable component that renders an emoji icon for a tab.
//
// Props:
// emoji — the emoji character to show
// color — passed by the tab bar (gold when active, grey when inactive)
//
// WHY we need a component at all:
// tabBarIcon expects a React element (JSX), not a plain string.
// Wrapping in a component lets us control size and apply the active colour.
import { Text } from "react-native";
function TabIcon({ emoji, color }: { emoji: string; color: string }) {
// (i) Receives the emoji character and the current color.
// When inactive, color is COLOURS.inactiveTab (grey).
// When active, color is COLOURS.activeTab (gold).
// (ii) Compares color to the active gold to determine if it is active.
// Why opacity? Because emoji colours can’t be changed with color – they keep their inherent colour.
// By reducing opacity, we visually “grey out” inactive tabs.
const isActive = color === COLOURS.activeTab;
// Emojis are simply characters, but we need to wrap them in a Text to control their size and opacity.
// (iii) We use opacity to visually dim inactive emoji icons.
// Sets opacity to 1.0 when active, 0.45 when inactive, creating a dimming effect.
return (
<Text
style={{
fontSize: 22,
opacity: isActive ? 1.0 : 0.45, // dim inactive tabs
textAlign: "center",
// Negative margin compensates for the emoji's natural padding
marginBottom: -2,
}}
>
{emoji}
</Text>
);
}
With the tab bar layout in place, we need the actual screen files so that each tab has content to display.
Right now we won’t build any real functionality – that comes in later posts. Instead we’ll create a reusable placeholder pattern that:
Think of these placeholders as the blueprint pinned to an empty wall. They tell you what room you’re in and when the furniture will arrive.
All five screens use the exact same layout structure, just with different emojis, titles, and descriptions.
On every screen the user sees:
┌─────────────────────────────┐
│ [emoji] │ ← large emoji at centre
│ [Tab Name] │ ← large title
│ [description] │ ← what this tab will do
│ ───────────────────────── │
│ Coming in Chunk NN │ ← reminder of when real content arrives
└─────────────────────────────┘
The styling is identical across all screens – dark navy background, gold title, muted text, and a subtle badge with a border. This consistency is deliberate: it reinforces the dark theme we established in the tab bar and header.
Why Placeholders Are Useful Now
Each screen is a single React component inside app/(tabs)/. The file name must exactly match the name prop we used in the tab layout, namely verse.tsx, videos.tsx, online.tsx, prayers.tsx, and diary.tsx.
Let’s build them one by one.
app/(tabs)/verse.tsx// app/(tabs)/verse.tsx
//
// VERSE TAB — placeholder
//
// What this will become:
// Left column: scrollable list of verses from daily_plan.txt
// Right column: reference, source, verse text, Prev/Today/Next buttons
//
// Data sources:
// 1. Personal daily plan + offline KJV JSON
// 2. OurManna.com API (online fallback)
// 3. YouVersion API (online, requires API key)
import { View, Text, StyleSheet } from "react-native";
// This component is self‑contained.
// It uses a View that takes up the entire screen (flex:1)
// and centres its children (alignItems: "center" + justifyContent: "center").
// The background colour is the same #1a1f2e we used in the tab layout,
// ensuring a seamless dark appearance.
export default function VerseScreen() {
return (
<View style={styles.container}>
<Text style={styles.emoji}>📖</Text>
<Text style={styles.title}>Scripture</Tex>
<Text style={styles.description}>
Daily Bible verse from your personal reading plan.{"\n"}
Supports offline KJV and online sources.
</Text>
<Text style={styles.coming}>Full content coming later on...</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#1a1f2e",
alignItems: "center",
justifyContent: "center",
padding: 32,
},
emoji: {
fontSize: 52,
marginBottom: 16,
},
title: {
fontSize: 26,
fontWeight: "bold",
color: "#c9a84c", // gold
marginBottom: 12,
},
description: {
fontSize: 15,
color: "#9ca3af", // muted
textAlign: "center",
lineHeight: 24,
marginBottom: 32,
},
coming: {
fontSize: 12,
color: "#4b5563", // very muted
borderWidth: 1,
borderColor: "#2d3550",
borderRadius: 6,
paddingHorizontal: 14,
paddingVertical: 6,
},
});
app/(tabs)/videos.tsx// app/(tabs)/videos.tsx
//
// VIDEOS TAB — placeholder
//
// What this will become:
// Left column: list of prayer videos from prayer_videos.txt
// Right column: YouTube embed via react-native-webview
//
// NOTE: Unlike the Python app (which needed a local HTTP server to work
// around error 152), React Native's WebView loads YouTube embeds directly
// without any workaround.
import { View, Text, StyleSheet } from "react-native";
// The structure is identical to the verse screen.
// The only differences are the emoji, the title, and the chunk number.
export default function VideosScreen() {
return (
<View style={styles.container}>
<Text style={styles.emoji}>🙏</Text>
<Text style={styles.title}>Prayer Videos</Text>
<Text style={styles.description}>
YouTube prayer and worship videos.{"\n"}
Manage your playlist in prayer_videos.txt.
</Text>
<Text style={styles.coming}>Full content coming later on...</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#1a1f2e",
alignItems: "center",
justifyContent: "center",
padding: 32,
},
emoji: { fontSize: 52, marginBottom: 16 },
title: { fontSize: 26, fontWeight: "bold", color: "#c9a84c", marginBottom: 12 },
description: { fontSize: 15, color: "#9ca3af", textAlign: "center", lineHeight: 24, marginBottom: 32 },
coming: { fontSize: 12, color: "#4b5563", borderWidth: 1, borderColor: "#2d3550", borderRadius: 6, paddingHorizontal: 14, paddingVertical: 6 },
});
app/(tabs)/online.tsx// app/(tabs)/online.tsx
//
// ONLINE PRAYER TAB — placeholder
//
// What this will become:
// Left column: list of prayer websites from prayeronline.txt
// Right column: WebView showing the selected website embedded in the app
//
// Sites planned:
// Sacred Space, Pray As You Go, Universalis, Pray.com, Bible Gateway...
import { View, Text, StyleSheet } from "react-native";
// Again the same pattern. This screen reminds us that the app will embed external websites,
// keeping the user inside the native experience rather than bouncing them to a browser.
export default function OnlineScreen() {
return (
<View style={styles.container}>
<Text style={styles.emoji}>🌐</Text>
<Text style={styles.title}>Prayer Sites</Text>
<Text style={styles.description}>
Browse curated prayer websites without leaving the app.{"\n"}
Add your own sites to prayeronline.txt.
</Text>
<Text style={styles.coming}>Full content coming later on... </Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1, backgroundColor: "#1a1f2e", alignItems: "center",
justifyContent: "center", padding: 32,
},
emoji: { fontSize: 52, marginBottom: 16 },
title: { fontSize: 26, fontWeight: "bold", color: "#c9a84c", marginBottom: 12 },
description: { fontSize: 15, color: "#9ca3af", textAlign: "center", lineHeight: 24, marginBottom: 32 },
coming: { fontSize: 12, color: "#4b5563", borderWidth: 1, borderColor: "#2d3550", borderRadius: 6, paddingHorizontal: 14, paddingVertical: 6 },
});
app/(tabs)/prayers.tsx// app/(tabs)/prayers.tsx
//
// MY PRAYERS TAB — placeholder
//
// What this will become:
// Left column: list of prayers from the SQLite database (prayer_db)
// Right column: prayer title, description, date, page-by-page content viewer
//
// Database schema (same as Python prayer_db.py):
// myprayers: id_myprayer, title, description, date, image
// pages: id, myprayerid (FK), page_number, content, image
//
// The Ignatian morning prayer (5 pages: Presence, Freedom, Gratitude,
// Petition, Review) is seeded automatically on first launch.
import { View, Text, StyleSheet } from "react-native";
export default function PrayersScreen() {
return (
<View style={styles.container}>
<Text style={styles.emoji}>✝</Text>
<Text style={styles.title}>My Prayers</Text>
<Text style={styles.description}>
Personal prayer library with multi-page prayers.{"\n"}
Navigate page by page, like Sacred Space.
</Text>
<Text style={styles.coming}>Full content coming later on...</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1, backgroundColor: "#1a1f2e", alignItems: "center",
justifyContent: "center", padding: 32,
},
emoji: { fontSize: 52, marginBottom: 16 },
title: { fontSize: 26, fontWeight: "bold", color: "#c9a84c", marginBottom: 12 },
description: { fontSize: 15, color: "#9ca3af", textAlign: "center", lineHeight: 24, marginBottom: 32 },
coming: { fontSize: 12, color: "#4b5563", borderWidth: 1, borderColor: "#2d3550", borderRadius: 6, paddingHorizontal: 14, paddingVertical: 6 },
});
app/(tabs)/diary.tsx// app/(tabs)/diary.tsx
//
// PRAYER DIARY TAB — placeholder
//
// What this will become:
// Left column: list of diary entries (one .md file per day)
// Right column: Markdown editor with auto-save + live Preview toggle
//
// Storage: expo-file-system writes .md files to the app's document directory.
// This is the React Native equivalent of ~/dotfiles/myArch/config/bible/prayer_diary/
import { View, Text, StyleSheet } from "react-native";
// The diary tab completes the set. Like the others, it is a promise of future development
// – but for now it’s a perfectly styled placeholder that matches the app’s dark aesthetic.
export default function DiaryScreen() {
return (
<View style={styles.container}>
<Text style={styles.emoji}>📔</Text>
<Text style={styles.title}>Prayer Diary</Text>
<Text style={styles.description}>
A private Markdown journal for your daily prayer.{"\n"}
Auto-saved to the app's secure storage.
</Text>
<Text style={styles.coming}>Full content coming in Chunks 25–26</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1, backgroundColor: "#1a1f2e", alignItems: "center",
justifyContent: "center", padding: 32,
},
emoji: { fontSize: 52, marginBottom: 16 },
title: { fontSize: 26, fontWeight: "bold", color: "#c9a84c", marginBottom: 12 },
description: { fontSize: 15, color: "#9ca3af", textAlign: "center", lineHeight: 24, marginBottom: 32 },
coming: { fontSize: 12, color: "#4b5563", borderWidth: 1, borderColor: "#2d3550", borderRadius: 6, paddingHorizontal: 14, paddingVertical: 6 },
});
app/index.tsx button targetIn the previous steps we built the tab bar layout (_layout.tsx) and created the five placeholder screens. Now we need to complete the navigation circle: make the home screen button land on the Verse tab, understand why the root layout needs no changes, and finally run the app to confirm everything works flawlessly.
In the previous post, we added a button on the home screen that attempted to navigate to /(tabs)/verse — which did not
exist yet. Now it does. Now that we’ve built the Verse tab, the route is live — the button should work immediately.
// Open app/index.tsx and locate the button’s onPress handler. It should look like this:
[...]
<TouchableOpacity
style={styles.button}
onPress={() => router.push("/(tabs)/verse")}
activeOpacity={0.8}
>
<Text style={styles.buttonText}>Open Prayer Companion</Text>
</TouchableOpacity>
Because /(tabs)/verse is inside the (tabs) group, Expo Router knows to:
What Happens When the Button Is Pressed?
The root layout (app/_layout.tsx) is the outermost wrapper of our app. In previous posts, we set it up as a Stack navigator, which gives us the header bar and back button behaviour for the home screen and any future screens outside the tabs.
Do we need to add the (tabs) group to the root layout? The answer is no. Expo Router’s file‑based system discovers navigators automatically:
This nesting is automatic and hierarchical. The root Stack remains the “master” navigator for screens that are not inside any tab (like app/index.tsx or a future app/settings.tsx). The tab group is simply another screen in that Stack — but one that itself contains a nested tab navigator.
Root Stack (app/_layout.tsx)
├── Screen: index (home) ← route "/"
├── Screen: settings (if added) ← route "/settings"
└── Screen: (tabs) ← route "/(tabs)" – renders TabLayout
├── Tab: verse ← route "/(tabs)/verse"
├── Tab: videos ← route "/(tabs)/videos"
├── Tab: online ← route "/(tabs)/online"
├── Tab: prayers ← route "/(tabs)/prayers"
└── Tab: diary ← route "/(tabs)/diary"
Because the root layout is a Stack, pushing /(tabs)/verse from the home screen simply adds the (tabs) entry to the stack. The tab bar’s own navigation (switching between tabs) is internal to that stack entry — it does not push new screens onto the root stack.
# If your emulator or device is already running from earlier, just restart the Expo server
npx expo start
# Then, press a for Android or i for iOS (Mac only) in the terminal.
Expo projects come with TypeScript support. To make sure your types are correct without running the app, execute:
# No output = no errors. If you see error messages,
# they will point to the exact file and line number.
# Common type issues at this stage include missing imports or incorrectly typed props.
# Fix them before continuing.
npx tsc --noEmit
prayer-companion/
├── app/
│ ├── _layout.tsx ← root layout (Stack navigation shell)
│ ├── index.tsx ← home screen with navigation button
│ └── (tabs)/
│ ├── _layout.tsx ← tab bar configuration (colours, icons, headers)
│ ├── verse.tsx ← Verse placeholder
│ ├── videos.tsx ← Videos placeholder
│ ├── online.tsx ← Online placeholder
│ ├── prayers.tsx ← Prayers placeholder
│ └── diary.tsx ← Diary placeholder
├── assets/ ← images, fonts
├── app.json ← Expo configuration
├── package.json
└── tsconfig.json