How to implement bottom navigation in SwiftUI
Wrap your top-level destination views in a TabView and attach a .tabItem modifier to each child — SwiftUI renders the tab bar automatically. Bind a @State selection variable to switch tabs programmatically.
import SwiftUI
struct ContentView: View {
@State private var selectedTab = 0
var body: some View {
TabView(selection: $selectedTab) {
HomeView()
.tabItem { Label("Home", systemImage: "house") }
.tag(0)
SearchView()
.tabItem { Label("Search", systemImage: "magnifyingglass") }
.tag(1)
ProfileView()
.tabItem { Label("Profile", systemImage: "person") }
.tag(2)
}
}
}
Full implementation
The example below builds a three-tab app with a notification badge on the inbox tab, programmatic deep-link support via a button, and proper VoiceOver accessibility labels. Each tab hosts a lightweight placeholder view so the preview compiles without extra files. The .tag(_:) values are typed Int here, but you can use any Hashable type — an enum works great for larger apps.
import SwiftUI
// MARK: - Tab enum for type-safe selection
enum AppTab: Int, CaseIterable {
case home, inbox, profile
var title: String {
switch self {
case .home: return "Home"
case .inbox: return "Inbox"
case .profile: return "Profile"
}
}
var icon: String {
switch self {
case .home: return "house.fill"
case .inbox: return "envelope.fill"
case .profile: return "person.fill"
}
}
}
// MARK: - Root content view
struct ContentView: View {
@State private var selectedTab: AppTab = .home
@State private var unreadCount: Int = 3
var body: some View {
TabView(selection: $selectedTab) {
// Home tab
HomeView(onGoToInbox: { selectedTab = .inbox })
.tabItem {
Label(AppTab.home.title,
systemImage: AppTab.home.icon)
}
.tag(AppTab.home)
// Inbox tab — badge shows unread count
InboxView(unreadCount: $unreadCount)
.tabItem {
Label(AppTab.inbox.title,
systemImage: AppTab.inbox.icon)
}
.tag(AppTab.inbox)
.badge(unreadCount)
// Profile tab
ProfileView()
.tabItem {
Label(AppTab.profile.title,
systemImage: AppTab.profile.icon)
}
.tag(AppTab.profile)
}
// Tint applies to selected tab icon + badge
.tint(.indigo)
}
}
// MARK: - Placeholder views
struct HomeView: View {
var onGoToInbox: () -> Void
var body: some View {
NavigationStack {
VStack(spacing: 20) {
Text("Welcome home!")
.font(.title2.bold())
Button("Jump to Inbox") { onGoToInbox() }
.buttonStyle(.borderedProminent)
}
.navigationTitle("Home")
}
}
}
struct InboxView: View {
@Binding var unreadCount: Int
var body: some View {
NavigationStack {
List(0..
How it works
-
Type-safe tabs with an enum.
AppTab: Int, CaseIterablegives each destination a stable raw value, a human-readable title, and an SF Symbols icon name — all in one place. PassingAppTabas the generic type toTabView(selection:)means the compiler catches typos at build time rather than at runtime. -
Programmatic tab switching.
@State private var selectedTab: AppTab = .homeis bound directly toTabView(selection: $selectedTab). WritingselectedTab = .inboxfrom anywhere — a button, a push notification handler, a deep-link URL — instantly switches the visible tab. -
Badge on the Inbox tab. The
.badge(unreadCount)modifier renders a red pill with the count over the tab icon. WhenunreadCountreaches zero the badge disappears automatically. Passing aStringinstead of anIntlets you show arbitrary text like"New". -
Tint color.
.tint(.indigo)applied to theTabViewcolors the selected tab icon and badge — no need to setUITabBar.appearance().tintColorin UIKit bridging code. -
VoiceOver accessibility.
Label("Home", systemImage: "house.fill")provides both the visual icon and an accessible text label. VoiceOver reads "Home tab, 1 of 3" automatically — no extra.accessibilityLabelneeded on the tab item itself.
Variants
Custom tab bar appearance (background color, shadow)
struct ContentView: View {
@State private var selectedTab: AppTab = .home
init() {
// Applies globally — do this once at app startup
let appearance = UITabBarAppearance()
appearance.configureWithOpaqueBackground()
appearance.backgroundColor = UIColor.systemBackground
// Remove default top-border shadow
appearance.shadowColor = .clear
UITabBar.appearance().standardAppearance = appearance
UITabBar.appearance().scrollEdgeAppearance = appearance
}
var body: some View {
TabView(selection: $selectedTab) {
HomeView(onGoToInbox: { selectedTab = .inbox })
.tabItem { Label("Home", systemImage: "house.fill") }
.tag(AppTab.home)
// … other tabs
}
.tint(.indigo)
}
}
Hiding the tab bar on pushed detail screens
When you push a detail view inside a NavigationStack that lives inside a tab, you may want to hide the tab bar so it doesn't compete with the detail UI. Apply .toolbar(.hidden, for: .tabBar) to the destination view — SwiftUI slides the bar away during the navigation transition and restores it on pop automatically. No UIKit hacks needed.
Common pitfalls
-
iOS version for
.badge:.badge(_:)with anIntrequires iOS 15+, and theStringoverload requires iOS 17+. Since this guide targets iOS 17+ you're fine, but if you ever lower the deployment target guard with@available. -
Don't embed
TabViewinsideNavigationStack: A common mistake is wrappingTabViewinside aNavigationStack. This breaks the tab bar layout and causes double navigation bars. The correct nesting isTabView → (NavigationStack per tab) → detail views. -
Tab state is per-tab, not shared: Each tab maintains its own navigation stack independently. If you need cross-tab state (e.g., a shopping cart count visible on multiple tabs), lift that state into a shared
@Observablemodel injected via.environmentrather than storing it inside a single tab's view.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement bottom navigation in SwiftUI for iOS 17+. Use TabView with a type-safe enum for tab selection. Support programmatic tab switching and badge counts. Make it accessible (VoiceOver labels on each tab item). Add a #Preview with realistic sample data.
In the Soarias Build phase, paste this prompt after your screens are scaffolded — Claude Code will wire up the TabView shell, connect your existing destination views, and add the programmatic deep-link handler in one pass.
Related
FAQ
Does this work on iOS 16?
The core TabView and .tabItem APIs work back to iOS 14. However, the .badge(String:) overload and some UITabBarAppearance scroll-edge options require iOS 17+. If you lower your deployment target, wrap those calls in @available(iOS 17, *) guards.
How do I reset a tab's navigation stack when the user taps the already-selected tab?
Store a NavigationPath per tab in your app model. In the onChange(of: selectedTab) modifier, if the new and old values are equal, call path.removeLast(path.count) to pop back to the root — this mirrors the behavior users expect from native apps like Mail and Maps.
What's the UIKit equivalent?
In UIKit you'd create a UITabBarController, set its viewControllers array, and configure each controller's tabBarItem with a title, image, and badgeValue. SwiftUI's TabView wraps this under the hood, so you get the same native chrome with a fraction of the boilerplate.
Last reviewed: 2026-05-11 by the Soarias team.