```html SwiftUI: How to Animated Transitions (iOS 17+, 2026)

How to Implement Animated Transitions in SwiftUI

iOS 17+ Xcode 16+ Intermediate APIs: matchedGeometryEffect Updated: May 11, 2026
TL;DR

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

  1. @Namespace private var heroNamespace — declares a shared animation namespace scoped to AnimatedTransitionsView. 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.
  2. Matching IDs on both sidesmatchedGeometryEffect(id: "card-\(item.id)", in: namespace) is applied to both CardThumbnail and CardDetail. SwiftUI uses these matching IDs to interpolate frame, position, and shape between the two views during the animation.
  3. withAnimation(.spring(response:dampingFraction:)) — toggling selectedItem inside a spring animation block tells the render loop to smoothly morph geometry between the matched views. The spring parameters control the feel — lower response is snappier, higher dampingFraction removes bounce.
  4. .zIndex(1) on the overlay — when CardDetail appears inside the ZStack, it must paint above the grid. Without explicit zIndex, SwiftUI uses declaration order but animation insertions can briefly flicker under other views.
  5. 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

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?
Not directly — 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?
In UIKit, hero transitions are implemented via 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.

```