What You Will Build

A horizontal tab bar where a colored capsule indicator smoothly slides between tabs as you tap them. The indicator automatically resizes to match each tab label's width using matchedGeometryEffect. Below the tabs, a paged TabView shows content synchronized with the selected tab.

Why This Pattern Matters

The sliding indicator pattern is used in Twitter/X, YouTube, and Google's Material Design tabs. Before matchedGeometryEffect, building this required manual geometry calculations and preference keys. Now SwiftUI handles the transition automatically.

Key SwiftUI Concepts

  • @Namespace creates a shared coordinate space for geometry matching.
  • matchedGeometryEffect animates a shape between different positions and sizes.
  • TabView with .page style for swipeable content pages.

Step 1: Set Up the Namespace and State

@State private var activeTab: String = "Home"
@Namespace private var tabNamespace
let tabs = ["Home", "Explore", "Trending", "Profile"]

Step 2: Build the Tab Bar with Sliding Indicator

The key trick is that only the active tab renders a filled Capsule with the matched geometry ID. Inactive tabs render a clear capsule. SwiftUI animates the transition between them:

ScrollView(.horizontal, showsIndicators: false) {
    HStack(spacing: 20) {
        ForEach(tabs, id: \.self) { tab in
            VStack(spacing: 8) {
                Text(tab)
                    .font(.headline)
                    .foregroundStyle(activeTab == tab ? .primary : .secondary)

                if activeTab == tab {
                    Capsule()
                        .fill(.blue)
                        .frame(height: 3)
                        .matchedGeometryEffect(id: "TAB_INDICATOR", in: tabNamespace)
                } else {
                    Capsule()
                        .fill(.clear)
                        .frame(height: 3)
                }
            }
            .contentShape(Rectangle())
            .onTapGesture {
                withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
                    activeTab = tab
                }
            }
        }
    }
    .padding(.horizontal)
}

Step 3: Sync with a Paged TabView

TabView(selection: $activeTab) {
    ForEach(tabs, id: \.self) { tab in
        VStack {
            Image(systemName: tabIcon(for: tab))
                .font(.system(size: 60))
                .foregroundStyle(.blue.gradient)
            Text(tab)
                .font(.title2)
                .padding(.top, 8)
        }
        .tag(tab)
    }
}
.tabViewStyle(.page(indexDisplayMode: .never))

How matchedGeometryEffect Works

When you assign the same ID and namespace to two views, SwiftUI treats them as the same element moving between positions. As the active tab changes, the filled capsule "moves" to the new tab. In reality, SwiftUI interpolates the frame (position and size) between the old and new locations during the animation.

Tips and Pitfalls

  • The clear Capsule placeholder is essential. Without it, the indicator cannot compute where to animate from and to. Both states need a view with compatible frames.
  • .contentShape(Rectangle()) makes the entire column tappable, not just the text.
  • Spring damping 0.7 prevents the indicator from overshooting. Lower values (0.5) create a bouncier slide.
  • Namespace must be unique per view. If you have two tab bars, each needs its own @Namespace.
  • ScrollView wrapping allows the tab bar to handle more tabs than fit on screen.

iOS Version: iOS 16+ (matchedGeometryEffect available from iOS 14, .page TabView from iOS 15)

Get New Tutorials by Email

No spam. Just clear, practical breakdowns you can apply right away.

Enjoy this tutorial?

Get new practical tech tutorials in your inbox.