How to Implement Animated Transitions in SwiftUI
Use @Namespace plus
.matchedGeometryEffect(id:in:)
to smoothly morph a view's frame and identity from one location to another.
Wrap your state toggle in withAnimation and SwiftUI handles the interpolation automatically.
struct HeroTransitionView: View {
@Namespace private var hero
@State private var isExpanded = false
var body: some View {
if isExpanded {
RoundedRectangle(cornerRadius: 24)
.matchedGeometryEffect(id: "card", in: hero)
.frame(maxWidth: .infinity, maxHeight: 300)
.onTapGesture {
withAnimation(.spring(duration: 0.45)) { isExpanded = false }
}
} else {
RoundedRectangle(cornerRadius: 12)
.matchedGeometryEffect(id: "card", in: hero)
.frame(width: 120, height: 80)
.onTapGesture {
withAnimation(.spring(duration: 0.45)) { isExpanded = true }
}
}
}
}
Full implementation
The example below builds a card grid where tapping any card expands it into a full-screen detail view using a hero transition. The @Namespace links the thumbnail and the expanded card so SwiftUI can interpolate position, size, and corner radius in one smooth motion. An overlay sits on top of the list while the card is expanded, ensuring the animation plays over the entire screen with no z-order issues.
import SwiftUI
struct Item: Identifiable {
let id: Int
let title: String
let color: Color
}
struct AnimatedTransitionsView: View {
@Namespace private var heroNamespace
@State private var selectedItem: Item? = nil
let items: [Item] = [
Item(id: 1, title: "Ocean", color: .blue),
Item(id: 2, title: "Forest", color: .green),
Item(id: 3, title: "Sunset", color: .orange),
Item(id: 4, title: "Storm", color: .purple),
]
var body: some View {
ZStack {
// Card grid
ScrollView {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) {
ForEach(items) { item in
CardThumbnail(item: item, namespace: heroNamespace)
.onTapGesture {
withAnimation(.spring(response: 0.4, dampingFraction: 0.82)) {
selectedItem = item
}
}
}
}
.padding()
}
.navigationTitle("Gallery")
// Expanded detail overlay
if let item = selectedItem {
CardDetail(item: item, namespace: heroNamespace) {
withAnimation(.spring(response: 0.4, dampingFraction: 0.82)) {
selectedItem = nil
}
}
.zIndex(1)
}
}
}
}
// MARK: - Thumbnail
struct CardThumbnail: View {
let item: Item
let namespace: Namespace.ID
var body: some View {
RoundedRectangle(cornerRadius: 16)
.fill(item.color.gradient)
.matchedGeometryEffect(id: "card-\(item.id)", in: namespace)
.frame(height: 140)
.overlay(alignment: .bottomLeading) {
Text(item.title)
.matchedGeometryEffect(id: "label-\(item.id)", in: namespace)
.font(.headline)
.foregroundStyle(.white)
.padding(12)
}
.shadow(radius: 4, y: 2)
}
}
// MARK: - Detail
struct CardDetail: View {
let item: Item
let namespace: Namespace.ID
let onDismiss: () -> Void
var body: some View {
VStack {
RoundedRectangle(cornerRadius: 28)
.fill(item.color.gradient)
.matchedGeometryEffect(id: "card-\(item.id)", in: namespace)
.frame(maxWidth: .infinity)
.frame(height: 420)
.overlay(alignment: .bottomLeading) {
Text(item.title)
.matchedGeometryEffect(id: "label-\(item.id)", in: namespace)
.font(.largeTitle.bold())
.foregroundStyle(.white)
.padding(24)
}
.overlay(alignment: .topTrailing) {
Button(action: onDismiss) {
Image(systemName: "xmark.circle.fill")
.font(.title)
.foregroundStyle(.white.opacity(0.85))
.padding()
}
.accessibilityLabel("Close \(item.title)")
}
Spacer()
}
.ignoresSafeArea()
}
}
#Preview {
NavigationStack {
AnimatedTransitionsView()
}
}
How it works
-
@Namespace private var heroNamespace— declares a shared animation namespace scoped toAnimatedTransitionsView. This namespace acts as a key that pairs views across the view hierarchy. It must live on a common ancestor of both the source and destination. -
Matching IDs on both sides —
matchedGeometryEffect(id: "card-\(item.id)", in: namespace)is applied to bothCardThumbnailandCardDetail. SwiftUI uses these matching IDs to interpolate frame, position, and shape between the two views during the animation. -
withAnimation(.spring(response:dampingFraction:))— togglingselectedIteminside a spring animation block tells the render loop to smoothly morph geometry between the matched views. The spring parameters control the feel — lowerresponseis snappier, higherdampingFractionremoves bounce. -
.zIndex(1)on the overlay — whenCardDetailappears inside theZStack, it must paint above the grid. Without explicitzIndex, SwiftUI uses declaration order but animation insertions can briefly flicker under other views. -
Label matched separately —
"label-\(item.id)"uses a distinct match ID so the title text morphs from the small card position to the large detail position independently, adding a polished secondary motion on top of the shape transition.
Variants
Tab-bar hero transition (pill indicator)
A common pattern is sliding a background pill under selected tab icons. Because the pill is always visible (just repositioned), use isSource: false on the destination to let the source drive geometry.
struct PillTabBar: View {
@Namespace private var tabNamespace
@State private var selected = 0
let tabs = ["house", "magnifyingglass", "bell", "person"]
var body: some View {
HStack(spacing: 0) {
ForEach(tabs.indices, id: \.self) { index in
ZStack {
if selected == index {
Capsule()
.fill(Color.accentColor)
.matchedGeometryEffect(id: "pill", in: tabNamespace)
.frame(width: 56, height: 36)
}
Image(systemName: tabs[index])
.foregroundStyle(selected == index ? .white : .secondary)
.frame(width: 56, height: 36)
}
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.spring(response: 0.3, dampingFraction: 0.75)) {
selected = index
}
}
.accessibilityLabel("Tab \(index + 1)")
}
}
.padding(6)
.background(.ultraThinMaterial, in: Capsule())
}
}
Custom insertion / removal transition
For views that appear and disappear without a counterpart, use the .transition() modifier with a built-in or custom AnyTransition. Combine effects with .asymmetric(insertion:removal:):
if showBanner {
BannerView()
.transition(.asymmetric(
insertion: .move(edge: .top).combined(with: .opacity),
removal: .scale(scale: 0.8).combined(with: .opacity)
))
}
Common pitfalls
-
⚠️ Both views must be in the view tree simultaneously.
matchedGeometryEffectneeds both the source and destination present during the animation frame. Removing the source before the animation completes causes a jump. UseZStackwith conditional visibility rather thanif/elsebranches that remove views immediately. -
⚠️ Duplicate IDs in the same namespace crash at runtime. If two views share the same
matchedGeometryEffectID and namespace simultaneously without one beingisSource: false, SwiftUI will warn and the animation breaks. In list-based hero animations, make IDs unique per item (e.g.,"card-\(item.id)"). -
⚠️ Don't nest matched views inside
ScrollViewoffsets. When the source view is inside a scroll container and the destination is outside, the coordinate space mismatch causes the animation to start from the wrong position. Prefer a full-screen overlay approach (as shown above) for card-expand patterns. -
⚠️ Accessibility: add dismiss affordance. VoiceOver users need a button or swipe gesture to collapse the expanded detail. Always include a visible "Close" button with an
accessibilityLabel, and consider.accessibilityAddTraits(.isButton)on tappable card thumbnails.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement animated transitions in SwiftUI for iOS 17+. Use matchedGeometryEffect with a @Namespace. Build a card grid where tapping a card expands it to a full-screen detail overlay with a hero animation. Make it accessible (VoiceOver labels, Close button). Add a #Preview with realistic sample data.
In the Soarias Build phase, paste this prompt into the implementation panel — Soarias will scaffold the namespace, matching IDs, and spring parameters so you can ship the animation in one iteration instead of debugging coordinate-space mismatches manually.
Related
FAQ
Does matchedGeometryEffect work on iOS 16?
matchedGeometryEffect was introduced in iOS 14, so it compiles and runs on iOS 16. However, the spring animation API used here — .spring(response:dampingFraction:) as a shorthand — requires iOS 17. On iOS 16 replace it with Animation.spring(response: 0.4, dampingFraction: 0.82, blendDuration: 0). The behavior and visual result are identical.
Can I animate between views in different NavigationStack layers?
matchedGeometryEffect requires both views to share a common ancestor in the same view tree during the animation. For push/pop transitions in NavigationStack, the framework replaces the destination view after the push completes, so the source view is no longer in the tree. The recommended workaround is the full-screen overlay approach shown above, or using .navigationTransition(.zoom(sourceID:in:)) introduced in iOS 18 for native zoom transitions.
What's the UIKit equivalent?
UIViewControllerAnimatedTransitioning with a custom UIViewControllerTransitioningDelegate. You manually snapshot the source view, add it to the transition container, animate its frame to the destination, then swap. matchedGeometryEffect replaces roughly 80–120 lines of UIKit boilerplate with a single modifier and a namespace declaration.
Last reviewed: 2026-05-11 by the Soarias team.