```html SwiftUI: How to Implement Bottom Navigation (iOS 17+, 2026)

How to implement bottom navigation in SwiftUI

iOS 17+ Xcode 16+ Beginner APIs: TabView Updated: May 11, 2026
TL;DR

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

  1. Type-safe tabs with an enum. AppTab: Int, CaseIterable gives each destination a stable raw value, a human-readable title, and an SF Symbols icon name — all in one place. Passing AppTab as the generic type to TabView(selection:) means the compiler catches typos at build time rather than at runtime.
  2. Programmatic tab switching. @State private var selectedTab: AppTab = .home is bound directly to TabView(selection: $selectedTab). Writing selectedTab = .inbox from anywhere — a button, a push notification handler, a deep-link URL — instantly switches the visible tab.
  3. Badge on the Inbox tab. The .badge(unreadCount) modifier renders a red pill with the count over the tab icon. When unreadCount reaches zero the badge disappears automatically. Passing a String instead of an Int lets you show arbitrary text like "New".
  4. Tint color. .tint(.indigo) applied to the TabView colors the selected tab icon and badge — no need to set UITabBar.appearance().tintColor in UIKit bridging code.
  5. 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 .accessibilityLabel needed 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 an Int requires iOS 15+, and the String overload 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 TabView inside NavigationStack: A common mistake is wrapping TabView inside a NavigationStack. This breaks the tab bar layout and causes double navigation bars. The correct nesting is TabView → (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 @Observable model injected via .environment rather 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.

```