NewAppLander — App landing pages in 60s$69$39
The Swift Kit logoThe Swift Kit
Tutorial

SwiftUI Custom Tab Bar Tutorial — Build Beautiful Bottom Navigation

A step-by-step guide to building a fully custom tab bar in SwiftUI. Covers animated selection indicators, floating action buttons, badge support, per-tab NavigationStacks, accessibility, and dark mode theming. Production-ready Swift code you can drop into your project today.

Ahmed GaganAhmed Gagan
14 min read

TL;DR

The default TabView works fine for prototypes, but shipping apps need custom tab bars for branded animations, floating action buttons, and badge indicators. This tutorial builds a complete custom tab bar from scratch: enum-based tabs, animated selection indicator with matchedGeometryEffect, a center FAB pattern, per-tab NavigationStack state, VoiceOver accessibility, and dark mode theming. All code is production-ready Swift you can copy directly. The Swift Kit ships with this tab bar architecture pre-built.

Tab navigation is the backbone of nearly every iOS app. Instagram, Twitter, Spotify, Apple Music, App Store — they all use a bottom tab bar as the primary navigation pattern. Apple's default TabView handles the basics, but the moment you need a custom selection indicator, a floating create button, or animated transitions between tabs, you hit its limits. This guide walks through building a fully custom tab bar in SwiftUI that looks and feels like it belongs in a top-charting app.

When Should You Build a Custom Tab Bar in SwiftUI?

Before writing a single line of custom code, you should understand when the default TabView is enough and when it is not. Over-engineering navigation is one of the most common time sinks in iOS development, and sometimes the stock component is genuinely the right choice.

The default TabView with .tabItem gives you a lot for free: correct safe area handling, automatic VoiceOver support, the standard iOS tab bar appearance that users recognize, and proper iPad sidebar adaptation with TabViewStyle in iOS 18. If your app has 3-5 tabs with standard icons and labels, and you do not need custom animations or a center action button, the default is perfectly fine.

You should build a custom tab bar when you need any of the following:

  • An animated selection indicator (sliding underline, pill background, or glow effect)
  • A floating action button in the center (like Instagram's create button or Spotify's shuffle)
  • Custom badge styles beyond the default red dot
  • Branded animations on tab selection (spring bounce, icon morph, or particle effects)
  • A translucent or gradient background that differs from the standard frosted glass
  • Non-standard tab layouts (icon-only, text-only, or asymmetric sizing)
// Default TabView — perfectly fine for many apps
struct DefaultTabExample: View {
    var body: some View {
        TabView {
            HomeView()
                .tabItem {
                    Label("Home", systemImage: "house.fill")
                }
            SearchView()
                .tabItem {
                    Label("Search", systemImage: "magnifyingglass")
                }
            ProfileView()
                .tabItem {
                    Label("Profile", systemImage: "person.fill")
                }
        }
    }
}

If that covers your needs, use it. If not, keep reading. We are going to build something much more interesting.

How Do You Build a Custom Tab Bar from Scratch?

The foundation of any custom tab bar is a tab enum that defines every tab in your app. This gives you type safety, compile-time exhaustive checking, and a single source of truth for tab metadata like icons, labels, and badge counts.

Step 1: Define the Tab Enum

// Models/AppTab.swift
import SwiftUI

enum AppTab: Int, CaseIterable, Identifiable {
    case home
    case search
    case create   // FAB — center button
    case activity
    case profile

    var id: Int { rawValue }

    var title: String {
        switch self {
        case .home:     "Home"
        case .search:   "Search"
        case .create:   "Create"
        case .activity: "Activity"
        case .profile:  "Profile"
        }
    }

    var icon: String {
        switch self {
        case .home:     "house"
        case .search:   "magnifyingglass"
        case .create:   "plus"
        case .activity: "bell"
        case .profile:  "person"
        }
    }

    var selectedIcon: String {
        switch self {
        case .home:     "house.fill"
        case .search:   "magnifyingglass"
        case .create:   "plus"
        case .activity: "bell.fill"
        case .profile:  "person.fill"
        }
    }

    /// Tabs that appear as regular items (not the center FAB)
    static var regularTabs: [AppTab] {
        allCases.filter { $0 != .create }
    }
}

The enum uses CaseIterable so we can loop over all tabs, and Identifiable for use in ForEach. The selectedIcon property lets us show a filled version of the SF Symbol when the tab is active — a subtle but important detail that matches Apple's own tab bar behavior.

Step 2: Build the Custom Tab Bar View

// Views/CustomTabBar.swift
import SwiftUI

struct CustomTabBar: View {
    @Binding var selectedTab: AppTab
    @Namespace private var tabNamespace

    // Badge counts — pass from your ViewModel or coordinator
    var badges: [AppTab: Int] = [:]

    var body: some View {
        HStack(spacing: 0) {
            // Left tabs (home, search)
            ForEach(AppTab.regularTabs.prefix(2)) { tab in
                tabButton(for: tab)
            }

            // Center FAB
            fabButton

            // Right tabs (activity, profile)
            ForEach(AppTab.regularTabs.suffix(2)) { tab in
                tabButton(for: tab)
            }
        }
        .padding(.horizontal, 8)
        .padding(.top, 12)
        .padding(.bottom, 28) // Account for home indicator
        .background(
            Rectangle()
                .fill(.ultraThinMaterial)
                .overlay(
                    Rectangle()
                        .fill(
                            LinearGradient(
                                colors: [.black.opacity(0.3), .black.opacity(0.1)],
                                startPoint: .top,
                                endPoint: .bottom
                            )
                        )
                )
                .overlay(alignment: .top) {
                    Divider().opacity(0.3)
                }
                .ignoresSafeArea(edges: .bottom)
        )
    }

    // MARK: - Regular Tab Button

    private func tabButton(for tab: AppTab) -> some View {
        Button {
            withAnimation(.spring(duration: 0.35, bounce: 0.3)) {
                selectedTab = tab
            }
        } label: {
            VStack(spacing: 4) {
                ZStack(alignment: .topTrailing) {
                    Image(systemName: selectedTab == tab ? tab.selectedIcon : tab.icon)
                        .font(.system(size: 22, weight: .medium))
                        .scaleEffect(selectedTab == tab ? 1.1 : 1.0)

                    // Badge
                    if let count = badges[tab], count > 0 {
                        Text("\(min(count, 99))")
                            .font(.system(size: 10, weight: .bold))
                            .foregroundStyle(.white)
                            .padding(.horizontal, 5)
                            .padding(.vertical, 1)
                            .background(Color.red, in: Capsule())
                            .offset(x: 10, y: -6)
                    }
                }
                .frame(height: 24)

                Text(tab.title)
                    .font(.system(size: 10, weight: selectedTab == tab ? .semibold : .regular))

                // Selection indicator pill
                if selectedTab == tab {
                    Capsule()
                        .fill(Color.accentColor)
                        .frame(width: 20, height: 3)
                        .matchedGeometryEffect(id: "tabIndicator", in: tabNamespace)
                } else {
                    Capsule()
                        .fill(Color.clear)
                        .frame(width: 20, height: 3)
                }
            }
            .foregroundStyle(selectedTab == tab ? Color.accentColor : .white.opacity(0.5))
            .frame(maxWidth: .infinity)
        }
        .buttonStyle(.plain)
    }

    // MARK: - Center FAB

    private var fabButton: some View {
        Button {
            withAnimation(.spring(duration: 0.3, bounce: 0.4)) {
                selectedTab = .create
            }
        } label: {
            ZStack {
                Circle()
                    .fill(
                        LinearGradient(
                            colors: [Color.accentColor, Color.accentColor.opacity(0.8)],
                            startPoint: .topLeading,
                            endPoint: .bottomTrailing
                        )
                    )
                    .frame(width: 52, height: 52)
                    .shadow(color: Color.accentColor.opacity(0.4), radius: 8, y: 4)

                Image(systemName: "plus")
                    .font(.system(size: 22, weight: .bold))
                    .foregroundStyle(.white)
                    .rotationEffect(.degrees(selectedTab == .create ? 45 : 0))
            }
            .offset(y: -16) // Float above the bar
        }
        .buttonStyle(.plain)
        .frame(maxWidth: .infinity)
    }
}

This tab bar has several production-quality details. The matchedGeometryEffect on the selection indicator creates a smooth sliding pill that follows the active tab. The FAB floats above the bar with an offset and shadow. Badge counts cap at 99. The icons swap between regular and filled variants. And the entire thing is built on .ultraThinMaterial for that native iOS frosted glass look.

Step 3: Wire It Into Your App

// Views/MainTabView.swift
import SwiftUI

struct MainTabView: View {
    @State private var selectedTab: AppTab = .home

    var body: some View {
        ZStack(alignment: .bottom) {
            // Tab content
            Group {
                switch selectedTab {
                case .home:
                    NavigationStack {
                        HomeView()
                    }
                case .search:
                    NavigationStack {
                        SearchView()
                    }
                case .create:
                    NavigationStack {
                        CreateView()
                    }
                case .activity:
                    NavigationStack {
                        ActivityView()
                    }
                case .profile:
                    NavigationStack {
                        ProfileView()
                    }
                }
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)

            // Custom tab bar
            CustomTabBar(
                selectedTab: $selectedTab,
                badges: [.activity: 3]
            )
        }
    }
}

The ZStack approach overlays the custom tab bar on top of the content. The content fills the full screen and the tab bar sits at the bottom with its own safe area padding. This is the standard architecture for custom tab bars because it gives you complete control over the bar's appearance and positioning.

Important: Each tab gets its own NavigationStack. We covered why this matters in our navigation patterns guide — wrapping a single stack around all tabs causes the tab bar to disappear on push navigation.

How Do You Add Animations to a Custom Tab Bar?

Animations transform a functional tab bar into one that feels alive. The three most impactful animation techniques for tab bars are the sliding selection indicator (already covered above with matchedGeometryEffect), icon scale and bounce on tap, and transition animations between tab content.

Animated Icon Bounce

Add a spring scale effect when a tab is tapped. This provides immediate tactile feedback that the tap registered, even before the content changes:

// Enhanced tab button with bounce animation
struct AnimatedTabButton: View {
    let tab: AppTab
    let isSelected: Bool
    let namespace: Namespace.ID
    let action: () -> Void

    @State private var isPressed = false

    var body: some View {
        Button {
            // Trigger haptic feedback
            let impact = UIImpactFeedbackGenerator(style: .light)
            impact.impactOccurred()
            action()
        } label: {
            VStack(spacing: 4) {
                Image(systemName: isSelected ? tab.selectedIcon : tab.icon)
                    .font(.system(size: 22, weight: .medium))
                    .symbolEffect(.bounce, value: isSelected)
                    .frame(height: 24)

                Text(tab.title)
                    .font(.system(size: 10, weight: isSelected ? .semibold : .regular))

                if isSelected {
                    Capsule()
                        .fill(Color.accentColor)
                        .frame(width: 20, height: 3)
                        .matchedGeometryEffect(id: "indicator", in: namespace)
                } else {
                    Capsule()
                        .fill(Color.clear)
                        .frame(width: 20, height: 3)
                }
            }
            .foregroundStyle(isSelected ? Color.accentColor : .white.opacity(0.5))
            .scaleEffect(isPressed ? 0.85 : 1.0)
            .frame(maxWidth: .infinity)
        }
        .buttonStyle(.plain)
        ._onButtonGesture { pressing in
            withAnimation(.spring(duration: 0.2)) {
                isPressed = pressing
            }
        } perform: {}
    }
}

The .symbolEffect(.bounce) modifier (iOS 17+) provides a native SF Symbols bounce animation when the isSelected value changes. Combined with the press-down scale via _onButtonGesture and haptic feedback, this creates an interaction that feels responsive and premium.

Tab Content Transition

By default, switching tabs instantly swaps the content. Adding a subtle transition makes the switch feel smoother. For a deeper dive on transition types, see our animations and transitions guide.

// In MainTabView — wrap the Group in a transition
Group {
    switch selectedTab {
    case .home:
        NavigationStack { HomeView() }
    case .search:
        NavigationStack { SearchView() }
    // ... other tabs
    }
}
.transition(.opacity.animation(.easeInOut(duration: 0.15)))
.id(selectedTab) // Force SwiftUI to treat each tab as a new view

The .id(selectedTab) modifier tells SwiftUI that the content view changes identity when the tab changes, which triggers the transition. Keep the duration short (0.1-0.2 seconds) — tab switches should feel instant with a polish layer, not sluggish.

How Do You Support a Floating Action Button in a Tab Bar?

The center FAB pattern is used by Instagram (create post), Spotify (shuffle), Twitter/X (compose), and dozens of other top apps. The key architectural decision is whether the FAB opens a new screen (navigating to a create flow) or triggers a modal action (opening a sheet or action menu).

Here is the modal approach, which is more common because it does not lose the user's place in the current tab:

// MainTabView with modal FAB
struct MainTabView: View {
    @State private var selectedTab: AppTab = .home
    @State private var showCreateSheet = false

    var body: some View {
        ZStack(alignment: .bottom) {
            // Tab content — only show regular tabs
            Group {
                switch selectedTab {
                case .home:
                    NavigationStack { HomeView() }
                case .search:
                    NavigationStack { SearchView() }
                case .activity:
                    NavigationStack { ActivityView() }
                case .profile:
                    NavigationStack { ProfileView() }
                case .create:
                    // FAB opens a sheet, not a tab
                    EmptyView()
                }
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)

            // Custom tab bar — intercept FAB tap
            CustomTabBar(
                selectedTab: $selectedTab,
                onFabTap: {
                    showCreateSheet = true
                },
                badges: [.activity: 3]
            )
        }
        .sheet(isPresented: $showCreateSheet) {
            NavigationStack {
                CreateView()
                    .toolbar {
                        ToolbarItem(placement: .cancellationAction) {
                            Button("Cancel") { showCreateSheet = false }
                        }
                    }
            }
            .presentationDetents([.large])
        }
    }
}

The onFabTap closure lets the tab bar trigger the sheet without owning the presentation state. The FAB button in the tab bar calls this closure instead of switching tabs. This keeps the selected tab unchanged, so when the user dismisses the sheet, they return exactly where they were.

Animated FAB with Expanding Menu

For apps that need multiple create actions (new post, new story, new reel), an expanding FAB menu provides a compact way to offer choices:

// Expanding FAB menu
struct ExpandingFAB: View {
    @Binding var isExpanded: Bool

    let actions: [(icon: String, label: String, action: () -> Void)] = [
        ("camera.fill", "Photo", {}),
        ("video.fill", "Video", {}),
        ("text.bubble.fill", "Post", {}),
    ]

    var body: some View {
        ZStack {
            // Dimming overlay
            if isExpanded {
                Color.black.opacity(0.4)
                    .ignoresSafeArea()
                    .onTapGesture {
                        withAnimation(.spring(duration: 0.3)) {
                            isExpanded = false
                        }
                    }
            }

            VStack(spacing: 12) {
                // Action buttons — staggered entrance
                if isExpanded {
                    ForEach(Array(actions.enumerated()), id: \.offset) { index, item in
                        Button {
                            withAnimation(.spring(duration: 0.3)) {
                                isExpanded = false
                            }
                            item.action()
                        } label: {
                            HStack(spacing: 10) {
                                Text(item.label)
                                    .font(.subheadline.weight(.semibold))
                                    .foregroundStyle(.white)

                                Image(systemName: item.icon)
                                    .font(.system(size: 16, weight: .semibold))
                                    .foregroundStyle(.white)
                                    .frame(width: 44, height: 44)
                                    .background(Color.accentColor, in: Circle())
                            }
                        }
                        .transition(
                            .asymmetric(
                                insertion: .scale(scale: 0.5)
                                    .combined(with: .opacity)
                                    .animation(
                                        .spring(duration: 0.35)
                                        .delay(Double(actions.count - 1 - index) * 0.05)
                                    ),
                                removal: .scale(scale: 0.5)
                                    .combined(with: .opacity)
                                    .animation(.easeIn(duration: 0.15))
                            )
                        )
                    }
                }

                // Main FAB button
                Button {
                    withAnimation(.spring(duration: 0.35, bounce: 0.3)) {
                        isExpanded.toggle()
                    }
                } label: {
                    Image(systemName: "plus")
                        .font(.system(size: 24, weight: .bold))
                        .foregroundStyle(.white)
                        .frame(width: 56, height: 56)
                        .background(Color.accentColor, in: Circle())
                        .rotationEffect(.degrees(isExpanded ? 45 : 0))
                        .shadow(color: Color.accentColor.opacity(0.4), radius: 8, y: 4)
                }
                .buttonStyle(.plain)
            }
        }
    }
}

The staggered entrance uses a reversed delay — the bottom item appears first, then each item above it appears with a slight delay, creating a natural fan-out effect. The plus icon rotates 45 degrees to become an X when expanded, signaling that tapping again will close the menu.

How Does TabView Compare to a Custom Tab Bar?

Here is a direct comparison to help you make the right architectural decision for your app:

FeatureDefault TabViewCustom Tab BarNavigationSplitView (iPad)
Setup complexityMinimal (5 lines)Moderate (100-200 lines)Moderate (sidebar + detail)
Custom animationsNoneFull controlLimited
Center FAB supportNoYesNo
Custom badge stylesRed dot only (.badge())Any styleRed dot only
VoiceOver supportAutomaticManual (must add)Automatic
iPad sidebar adaptationAutomatic (iOS 18+)Must build separatelyNative
Translucent backgroundStandard blurCustom material/gradientStandard blur
Safe area handlingAutomaticManual paddingAutomatic
Best forStandard apps, MVPs, quick shippingBranded apps, consumer products, custom UXProductivity apps, document-based apps, iPad-first

The general rule: start with the default TabView for your MVP. Switch to a custom tab bar when you have specific design requirements that the default cannot meet. If your app is primarily iPad-focused, consider NavigationSplitView with a sidebar instead of a tab bar entirely.

How Do You Handle Navigation Inside Tab Bar Items?

The biggest architectural challenge with custom tab bars is maintaining independent navigation state per tab. When a user drills into a detail screen on the Home tab, switches to Profile, then switches back to Home, they should see the detail screen exactly where they left it. This requires one NavigationStack per tab with separate path state.

// Navigation/TabCoordinator.swift
import SwiftUI

@Observable
@MainActor
final class TabCoordinator {
    var selectedTab: AppTab = .home
    var homePath = NavigationPath()
    var searchPath = NavigationPath()
    var activityPath = NavigationPath()
    var profilePath = NavigationPath()

    /// Pop to root for the given tab
    func popToRoot(tab: AppTab) {
        switch tab {
        case .home:     homePath = NavigationPath()
        case .search:   searchPath = NavigationPath()
        case .activity: activityPath = NavigationPath()
        case .profile:  profilePath = NavigationPath()
        case .create:   break // FAB has no stack
        }
    }

    /// Pop to root when tapping the already-selected tab
    func handleTabReselection(_ tab: AppTab) {
        if tab == selectedTab {
            popToRoot(tab: tab)
        } else {
            selectedTab = tab
        }
    }
}

The handleTabReselection method implements the standard iOS behavior: tapping the already-active tab scrolls to the top / pops to root. This is a convention users expect, and missing it makes your app feel broken.

// Updated MainTabView with coordinator
struct MainTabView: View {
    @State private var coordinator = TabCoordinator()
    @State private var showCreateSheet = false

    var body: some View {
        ZStack(alignment: .bottom) {
            Group {
                switch coordinator.selectedTab {
                case .home:
                    NavigationStack(path: $coordinator.homePath) {
                        HomeView()
                            .navigationDestination(for: HomeRoute.self) { route in
                                switch route {
                                case .detail(let id):
                                    DetailView(itemID: id)
                                case .settings:
                                    SettingsView()
                                }
                            }
                    }
                case .search:
                    NavigationStack(path: $coordinator.searchPath) {
                        SearchView()
                            .navigationDestination(for: SearchRoute.self) { route in
                                switch route {
                                case .result(let query):
                                    SearchResultsView(query: query)
                                case .detail(let id):
                                    DetailView(itemID: id)
                                }
                            }
                    }
                case .activity:
                    NavigationStack(path: $coordinator.activityPath) {
                        ActivityView()
                    }
                case .profile:
                    NavigationStack(path: $coordinator.profilePath) {
                        ProfileView()
                            .navigationDestination(for: ProfileRoute.self) { route in
                                switch route {
                                case .editProfile:
                                    EditProfileView()
                                case .followers:
                                    FollowersView()
                                }
                            }
                    }
                case .create:
                    EmptyView()
                }
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
            // Add bottom padding so content does not hide behind the tab bar
            .safeAreaPadding(.bottom, 80)

            CustomTabBar(
                selectedTab: Binding(
                    get: { coordinator.selectedTab },
                    set: { coordinator.handleTabReselection($0) }
                ),
                onFabTap: { showCreateSheet = true },
                badges: [.activity: 3]
            )
        }
        .environment(coordinator)
        .sheet(isPresented: $showCreateSheet) {
            NavigationStack {
                CreateView()
            }
        }
    }
}

Each tab has its own bound NavigationPath so navigation state is fully independent. The TabCoordinator is injected into the environment so any child view can programmatically switch tabs or push routes — essential for deep linking and notification handling. For a complete deep linking implementation, see our navigation patterns guide.

How Do You Make a Custom Tab Bar Accessible?

When you build a custom tab bar, you lose all the automatic VoiceOver support that the default TabView provides for free. You need to add it back manually. This is not optional — approximately 15-20% of iOS users rely on accessibility features, and Apple increasingly highlights accessible apps in editorial features. For a deep dive, read our accessibility and VoiceOver guide.

VoiceOver Annotations

// Accessible tab button
private func tabButton(for tab: AppTab) -> some View {
    Button {
        withAnimation(.spring(duration: 0.35, bounce: 0.3)) {
            selectedTab = tab
        }
    } label: {
        VStack(spacing: 4) {
            Image(systemName: selectedTab == tab ? tab.selectedIcon : tab.icon)
                .font(.system(size: 22, weight: .medium))
                .frame(height: 24)
            Text(tab.title)
                .font(.system(size: 10))
        }
        .foregroundStyle(selectedTab == tab ? Color.accentColor : .white.opacity(0.5))
        .frame(maxWidth: .infinity)
    }
    .buttonStyle(.plain)
    // VoiceOver: Announce as a tab
    .accessibilityLabel(tab.title)
    .accessibilityHint(selectedTab == tab ? "Selected" : "Double-tap to switch to \(tab.title)")
    .accessibilityAddTraits(selectedTab == tab ? [.isSelected, .isButton] : [.isButton])
    // Badge announcement
    .accessibilityValue(
        badges[tab].map { "\($0) notification\($0 == 1 ? "" : "s")" } ?? ""
    )
}

The key details: .accessibilityAddTraits(.isSelected) tells VoiceOver which tab is currently active. The .accessibilityValue announces badge counts so blind users know about pending notifications. The hint provides guidance on what double-tapping will do.

Dark Mode Theming with Design Tokens

A custom tab bar must look correct in both light and dark mode. Instead of hardcoding colors, use design tokens that adapt automatically. We covered this pattern in detail in our dark mode and design tokens guide. Here is how it applies to the tab bar:

// DesignSystem/TabBarTokens.swift
import SwiftUI

enum TabBarTokens {
    // Background
    static let background: Material = .ultraThinMaterial

    // Icon colors
    static func iconColor(isSelected: Bool, scheme: ColorScheme) -> Color {
        if isSelected {
            return Color.accentColor
        }
        return scheme == .dark ? .white.opacity(0.5) : .black.opacity(0.4)
    }

    // Label colors
    static func labelColor(isSelected: Bool, scheme: ColorScheme) -> Color {
        if isSelected {
            return Color.accentColor
        }
        return scheme == .dark ? .white.opacity(0.4) : .black.opacity(0.35)
    }

    // Selection indicator
    static let indicatorColor: Color = .accentColor
    static let indicatorHeight: CGFloat = 3
    static let indicatorWidth: CGFloat = 20

    // Spacing
    static let topPadding: CGFloat = 12
    static let bottomPadding: CGFloat = 28
    static let iconSize: CGFloat = 22
    static let labelSize: CGFloat = 10

    // FAB
    static let fabSize: CGFloat = 52
    static let fabOffset: CGFloat = -16
    static let fabShadowRadius: CGFloat = 8
}

// Usage in the tab bar
struct ThemedTabBar: View {
    @Binding var selectedTab: AppTab
    @Environment(\.colorScheme) private var colorScheme
    @Namespace private var ns

    var body: some View {
        HStack(spacing: 0) {
            ForEach(AppTab.regularTabs) { tab in
                Button {
                    withAnimation(.spring(duration: 0.35, bounce: 0.3)) {
                        selectedTab = tab
                    }
                } label: {
                    VStack(spacing: 4) {
                        Image(systemName: selectedTab == tab ? tab.selectedIcon : tab.icon)
                            .font(.system(size: TabBarTokens.iconSize, weight: .medium))
                            .foregroundStyle(TabBarTokens.iconColor(
                                isSelected: selectedTab == tab,
                                scheme: colorScheme
                            ))
                        Text(tab.title)
                            .font(.system(size: TabBarTokens.labelSize))
                            .foregroundStyle(TabBarTokens.labelColor(
                                isSelected: selectedTab == tab,
                                scheme: colorScheme
                            ))
                    }
                    .frame(maxWidth: .infinity)
                }
                .buttonStyle(.plain)
            }
        }
        .padding(.top, TabBarTokens.topPadding)
        .padding(.bottom, TabBarTokens.bottomPadding)
        .background(TabBarTokens.background)
    }
}

Centralizing every value into a tokens enum means you can change the entire tab bar appearance by editing one file. This is the same design system approach that The Swift Kit uses for every component — change two hex values in AppConfig.swift and the entire app re-themes, including the tab bar.

What Are the Most Common Tab Bar Mistakes?

After reviewing dozens of SwiftUI projects and building several shipping apps with custom tab bars, these are the mistakes that come up again and again:

Mistake 1: Forgetting Safe Area Padding

A custom tab bar overlays content with a ZStack. If you forget to add bottom padding to your content views, the last items in your lists will be hidden behind the tab bar. Always add .safeAreaPadding(.bottom, tabBarHeight) to your content or use a Spacer at the bottom.

Mistake 2: Losing Navigation State on Tab Switch

If you use a single NavigationStack for all tabs instead of one per tab, the navigation state resets every time the user switches tabs. We solved this above with the TabCoordinator pattern. Each tab gets its own NavigationPath.

Mistake 3: No Tap-to-Scroll-to-Top on Reselection

Every native iOS app scrolls to the top when you tap the already-selected tab. Users expect this behavior. Implement it with the handleTabReselection method in your coordinator, combined with a ScrollViewReader that scrolls to a top anchor.

// Inside your list view
struct HomeView: View {
    @Environment(TabCoordinator.self) private var coordinator

    var body: some View {
        ScrollViewReader { proxy in
            ScrollView {
                // Invisible anchor at the top
                Color.clear
                    .frame(height: 0)
                    .id("scrollTop")

                LazyVStack {
                    // Your content here
                }
            }
            .onChange(of: coordinator.homePath) { oldPath, newPath in
                // If path was reset (pop to root), scroll to top
                if newPath.isEmpty && !oldPath.isEmpty {
                    withAnimation {
                        proxy.scrollTo("scrollTop", anchor: .top)
                    }
                }
            }
        }
    }
}

Mistake 4: Skipping VoiceOver Support

As covered in the accessibility section above, a custom tab bar has zero built-in accessibility. You must add .accessibilityLabel, .accessibilityHint, .accessibilityAddTraits(.isSelected), and badge value announcements manually. Test with VoiceOver enabled on a real device.

Mistake 5: Hardcoding Colors Instead of Using Design Tokens

Sprinkling Color.white.opacity(0.5) throughout your tab bar code means dark mode and light mode require separate color values in dozens of places. Use a design token system (as shown above) so the entire tab bar themes correctly from a single source of truth.

Mistake 6: Making the FAB Too Small

Apple's Human Interface Guidelines recommend a minimum 44x44 point touch target. Many custom FABs are visually large but have a smaller interactive area due to padding. Always ensure the Button frame itself meets the 44pt minimum, not just the visual circle.

Ship Pre-Built Navigation with The Swift Kit

Every pattern in this tutorial — the enum-based tab model, the animated custom tab bar with matchedGeometryEffect, the floating action button, per-tab NavigationStack coordination, VoiceOver accessibility, and design token theming — is already implemented, tested, and ready to customize in The Swift Kit.

Instead of spending a week building and debugging custom navigation infrastructure, you get a production-ready tab bar architecture on day one. The tab bar is connected to the onboarding flow, the paywall, the auth system, and deep linking. Switch between custom and default tab bar styles with a single config change. Every animation has been tuned for 60fps on devices from iPhone SE to iPhone 16 Pro Max.

  • Custom tab bar with animated selection indicator and FAB support
  • TabCoordinator with independent NavigationStack paths per tab
  • Deep linking that routes to the correct tab and pushes the right screen
  • VoiceOver accessibility on every navigation component
  • Design token system for instant dark mode and theming (see our design tokens guide)
  • Onboarding integration that flows into the tab bar (see our onboarding template guide)

Stop rebuilding the same navigation infrastructure for every project. Grab The Swift Kit from the checkout page and start building features instead of plumbing. For a full overview of what is included, see the features page.

Share this article

Ready to ship your iOS app faster?

The Swift Kit gives you a production-ready SwiftUI codebase with onboarding, paywalls, auth, AI integrations, and more. Stop building boilerplate. Start building your product.

Get The Swift Kit