How to Build an Animated Tab Bar in SwiftUI
Hide the default TabView bar with .toolbar(.hidden, for: .tabBar), then overlay a custom HStack of tab buttons. Animate a sliding background pill between them using matchedGeometryEffect so transitions are silky smooth without a single withAnimation call needed at the call site.
@Namespace private var tabNamespace
@State private var selected: AppTab = .home
HStack {
ForEach(AppTab.allCases) { tab in
Button {
selected = tab
} label: {
Label(tab.title, systemImage: tab.icon)
.padding(.vertical, 10)
.padding(.horizontal, 20)
.background {
if selected == tab {
Capsule()
.fill(Color.accentColor)
.matchedGeometryEffect(id: "pill", in: tabNamespace)
}
}
.foregroundStyle(selected == tab ? .white : .secondary)
}
.animation(.spring(response: 0.35, dampingFraction: 0.75), value: selected)
}
}
.padding(6)
.background(.ultraThinMaterial, in: Capsule())
Full implementation
The approach keeps SwiftUI's TabView for its navigation stack management while replacing its visual chrome entirely. A custom AnimatedTabBar view drives selection through a binding, and matchedGeometryEffect handles the indicator interpolation across frames — no manual offset tracking required. The result is a floating pill bar that sits above content and responds instantly to taps with a spring animation.
import SwiftUI
// MARK: - Tab Model
enum AppTab: String, CaseIterable, Identifiable {
case home, explore, library, profile
var id: String { rawValue }
var title: String {
switch self {
case .home: return "Home"
case .explore: return "Explore"
case .library: return "Library"
case .profile: return "Profile"
}
}
var icon: String {
switch self {
case .home: return "house.fill"
case .explore: return "safari.fill"
case .library: return "books.vertical.fill"
case .profile: return "person.crop.circle.fill"
}
}
}
// MARK: - Animated Tab Bar
struct AnimatedTabBar: View {
@Binding var selection: AppTab
@Namespace private var namespace
var body: some View {
HStack(spacing: 0) {
ForEach(AppTab.allCases) { tab in
Button {
selection = tab
} label: {
VStack(spacing: 3) {
Image(systemName: tab.icon)
.font(.system(size: 18, weight: .semibold))
Text(tab.title)
.font(.caption2.weight(.medium))
}
.padding(.vertical, 10)
.frame(maxWidth: .infinity)
.foregroundStyle(selection == tab ? .white : .secondary)
.background {
if selection == tab {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(Color.accentColor)
.matchedGeometryEffect(id: "tabIndicator", in: namespace)
}
}
}
.buttonStyle(.plain)
.accessibilityLabel(tab.title)
.accessibilityAddTraits(selection == tab ? [.isSelected] : [])
.animation(
.spring(response: 0.38, dampingFraction: 0.72, blendDuration: 0),
value: selection
)
}
}
.padding(6)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 20, style: .continuous))
.shadow(color: .black.opacity(0.12), radius: 12, y: 4)
.padding(.horizontal, 20)
.padding(.bottom, 8)
}
}
// MARK: - Root View
struct ContentView: View {
@State private var selectedTab: AppTab = .home
var body: some View {
ZStack(alignment: .bottom) {
TabView(selection: $selectedTab) {
HomeScreen()
.tag(AppTab.home)
.toolbar(.hidden, for: .tabBar)
ExploreScreen()
.tag(AppTab.explore)
.toolbar(.hidden, for: .tabBar)
LibraryScreen()
.tag(AppTab.library)
.toolbar(.hidden, for: .tabBar)
ProfileScreen()
.tag(AppTab.profile)
.toolbar(.hidden, for: .tabBar)
}
AnimatedTabBar(selection: $selectedTab)
}
.ignoresSafeArea(edges: .bottom)
}
}
// MARK: - Placeholder Screens
struct HomeScreen: View {
var body: some View {
NavigationStack {
Text("Home").navigationTitle("Home")
}
}
}
struct ExploreScreen: View {
var body: some View {
NavigationStack {
Text("Explore").navigationTitle("Explore")
}
}
}
struct LibraryScreen: View {
var body: some View {
NavigationStack {
Text("Library").navigationTitle("Library")
}
}
}
struct ProfileScreen: View {
var body: some View {
NavigationStack {
Text("Profile").navigationTitle("Profile")
}
}
}
// MARK: - Preview
#Preview {
ContentView()
.tint(.indigo)
}
How it works
-
matchedGeometryEffect drives the animation. The
RoundedRectanglebackground is only rendered whenselection == tab. SwiftUI sees it disappear from one tab and reappear on the next, andmatchedGeometryEffect(id: "tabIndicator", in: namespace)interpolates the frame between the two positions — so the pill appears to slide rather than pop. - .toolbar(.hidden, for: .tabBar) suppresses the system bar. Applied per-tab content view, this iOS 16+ modifier tells SwiftUI not to render its built-in tab bar chrome, leaving your custom bar as the only visible control.
-
Spring animation is scoped to the button. The
.animation(.spring(...), value: selection)modifier is placed on each tab button, not a parent container, keeping the spring tightly coupled to the geometry change and preventing unintended animation on sibling views. -
Accessibility traits keep VoiceOver correct. Each button gets
.accessibilityAddTraits(.isSelected)conditionally, so VoiceOver announces "Home, selected, button" for the active tab — matching the behavior of the native tab bar. -
ZStack + ignoresSafeArea floats the bar. Placing
AnimatedTabBarin aZStackoverTabViewwith.ignoresSafeArea(edges: .bottom)lets content scroll underneath the bar while the material blur creates visual separation — without hardcoding any bottom padding.
Variants
Icon-only compact bar
Drop the Text label and use a circular pill for a slimmer look — ideal for apps with 5+ tabs.
Button {
selection = tab
} label: {
Image(systemName: tab.icon)
.font(.system(size: 20, weight: .semibold))
.frame(width: 44, height: 44)
.foregroundStyle(selection == tab ? .white : .secondary)
.background {
if selection == tab {
Circle()
.fill(Color.accentColor)
.matchedGeometryEffect(id: "tabIndicator", in: namespace)
}
}
}
.buttonStyle(.plain)
.accessibilityLabel(tab.title)
.animation(.spring(response: 0.35, dampingFraction: 0.7), value: selection)
Bouncy icon scale effect
Layer a .scaleEffect(selection == tab ? 1.15 : 1.0) on the icon image alongside the matchedGeometryEffect pill to give the selected icon a satisfying pop. Pair it with .symbolEffect(.bounce, value: selection) (iOS 17+) to trigger the SF Symbol bounce animation on each tap — zero extra code, maximum delight.
Common pitfalls
-
.toolbar(.hidden, for: .tabBar) requires iOS 16+, but is the correct modern replacement for the UIKit hack of setting
tabBar.isHidden = true. On iOS 17+ it's fully supported and safe to use without workarounds. -
Both views must be in the same namespace for matchedGeometryEffect to work. If you extract
AnimatedTabBarinto a separate file, pass the@Namespacedown as a parameter — don't create a second namespace in the child view, or the animation will break and the pill will jump instead of slide. -
Avoid nesting NavigationStack inside TabView tabs if you're also using NavigationSplitView. On iPad, the outer
NavigationSplitViewconflicts with per-tabNavigationStacks; keep one navigation paradigm per layout class. -
The floating bar obscures bottom content. Add
.safeAreaInset(edge: .bottom) { Color.clear.frame(height: 90) }to each tab's scroll view or list so the last row isn't hidden behind the pill bar.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement animated tab bar in SwiftUI for iOS 17+. Use TabView and matchedGeometryEffect. Make it accessible (VoiceOver labels, isSelected trait). Hide the system tab bar with .toolbar(.hidden, for: .tabBar). Add a #Preview with realistic sample data showing all 4 tabs.
In Soarias, paste this prompt during the Build phase after your screen scaffolding is in place — Claude Code will wire the tab binding, create the namespace, and generate placeholder screens in one pass.
Related
FAQ
Does this work on iOS 16?
Mostly yes — matchedGeometryEffect and .toolbar(.hidden, for: .tabBar) both landed in iOS 16. The only iOS 17-exclusive feature used here is .symbolEffect(.bounce) in the variant. If you need iOS 16 support, remove that modifier and the rest of the implementation compiles cleanly.
Can I use this with NavigationSplitView on iPad?
The floating bar works on iPhone universally. On iPad, TabView renders as a sidebar by default in iOS 18+, which conflicts with the floating bar approach. For a universal app, use horizontalSizeClass to conditionally show the custom bar only on compact width (iPhone and iPad slide-over), and fall back to the system sidebar on regular width.
What's the UIKit equivalent?
In UIKit you'd subclass UITabBarController, hide tabBar.isHidden = true, and add a custom UIView to the controller's view. The sliding indicator would require manual UIView.animate calls and explicit frame math. The SwiftUI approach with matchedGeometryEffect is dramatically less code — no frame calculations, no delegate callbacks, no subclassing.
Last reviewed: 2026-05-11 by the Soarias team.