```html SwiftUI: How to Build Hero Animation (iOS 17+, 2026)

How to Build a Hero Animation in SwiftUI

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

Declare a @Namespace, apply .matchedGeometryEffect(id:in:) with the same ID to both the source thumbnail and the expanded detail view, then toggle a boolean inside withAnimation(.spring) — SwiftUI interpolates position, size, and shape automatically.

struct HeroDemo: View {
    @Namespace private var hero
    @State private var isExpanded = false

    var body: some View {
        if isExpanded {
            RoundedRectangle(cornerRadius: 24)
                .fill(.indigo)
                .matchedGeometryEffect(id: "card", in: hero)
                .frame(maxWidth: .infinity, maxHeight: 320)
                .onTapGesture { withAnimation(.spring(duration: 0.45)) { isExpanded = false } }
        } else {
            RoundedRectangle(cornerRadius: 12)
                .fill(.indigo)
                .matchedGeometryEffect(id: "card", in: hero)
                .frame(width: 120, height: 80)
                .onTapGesture { withAnimation(.spring(duration: 0.45)) { isExpanded = true } }
        }
    }
}

Full implementation

The example below mimics a real-world card-to-detail hero transition: a scrollable list of album cards, each of which expands into a full-screen detail overlay when tapped. The namespace is defined at the parent level so both the card and the overlay share it, while an overlay modifier keeps the expanded view in the same view tree — a requirement for matchedGeometryEffect to work correctly.

import SwiftUI

// MARK: - Model
struct Album: Identifiable {
    let id = UUID()
    let title: String
    let artist: String
    let color: Color
}

// MARK: - Main view
struct HeroAnimationView: View {
    @Namespace private var heroNamespace
    @State private var selectedAlbum: Album? = nil

    let albums: [Album] = [
        Album(title: "Neon Skies",   artist: "The Drifters",  color: .indigo),
        Album(title: "Ember Days",   artist: "Calla Moon",    color: .orange),
        Album(title: "Glass Oceans", artist: "Pale Horses",   color: .teal),
        Album(title: "Quiet Fire",   artist: "Roman Atlas",   color: .pink),
    ]

    var body: some View {
        ScrollView {
            VStack(spacing: 16) {
                ForEach(albums) { album in
                    AlbumCard(album: album, namespace: heroNamespace) {
                        withAnimation(.spring(response: 0.45, dampingFraction: 0.8)) {
                            selectedAlbum = album
                        }
                    }
                }
            }
            .padding()
        }
        .navigationTitle("Albums")
        .overlay {
            if let album = selectedAlbum {
                AlbumDetail(album: album, namespace: heroNamespace) {
                    withAnimation(.spring(response: 0.45, dampingFraction: 0.8)) {
                        selectedAlbum = nil
                    }
                }
                .ignoresSafeArea()
                .transition(.identity) // let matchedGeometry handle motion
            }
        }
    }
}

// MARK: - Card (source)
struct AlbumCard: View {
    let album: Album
    var namespace: Namespace.ID
    var onTap: () -> Void

    var body: some View {
        Button(action: onTap) {
            HStack(spacing: 14) {
                RoundedRectangle(cornerRadius: 10)
                    .fill(album.color.gradient)
                    .matchedGeometryEffect(id: "art-\(album.id)", in: namespace)
                    .frame(width: 64, height: 64)
                VStack(alignment: .leading, spacing: 4) {
                    Text(album.title)
                        .matchedGeometryEffect(id: "title-\(album.id)", in: namespace)
                        .font(.headline)
                    Text(album.artist)
                        .matchedGeometryEffect(id: "artist-\(album.id)", in: namespace)
                        .font(.subheadline)
                        .foregroundStyle(.secondary)
                }
                Spacer()
            }
            .padding()
            .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
        }
        .buttonStyle(.plain)
        .accessibilityLabel("Open \(album.title) by \(album.artist)")
    }
}

// MARK: - Detail (destination)
struct AlbumDetail: View {
    let album: Album
    var namespace: Namespace.ID
    var onDismiss: () -> Void

    var body: some View {
        ZStack(alignment: .topTrailing) {
            ScrollView {
                VStack(spacing: 24) {
                    RoundedRectangle(cornerRadius: 24)
                        .fill(album.color.gradient)
                        .matchedGeometryEffect(id: "art-\(album.id)", in: namespace)
                        .frame(maxWidth: .infinity)
                        .frame(height: 320)

                    VStack(alignment: .leading, spacing: 8) {
                        Text(album.title)
                            .matchedGeometryEffect(id: "title-\(album.id)", in: namespace)
                            .font(.largeTitle.bold())
                        Text(album.artist)
                            .matchedGeometryEffect(id: "artist-\(album.id)", in: namespace)
                            .font(.title3)
                            .foregroundStyle(.secondary)
                        Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc vehicula lorem at mauris pellentesque, sed tincidunt risus varius.")
                            .font(.body)
                            .foregroundStyle(.secondary)
                            .padding(.top, 4)
                    }
                    .padding(.horizontal)
                    .frame(maxWidth: .infinity, alignment: .leading)
                }
            }
            .background(.background)

            Button(action: onDismiss) {
                Image(systemName: "xmark.circle.fill")
                    .font(.title)
                    .symbolRenderingMode(.hierarchical)
                    .foregroundStyle(.secondary)
            }
            .padding()
            .accessibilityLabel("Close \(album.title)")
        }
    }
}

// MARK: - Preview
#Preview {
    NavigationStack {
        HeroAnimationView()
    }
}

How it works

  1. @Namespace private var heroNamespace — Declares a unique animation namespace owned by HeroAnimationView. The namespace is passed down as a Namespace.ID value to both the card and the detail, so SwiftUI knows which views to animate between each other.
  2. Matching IDs with string interpolation — Each album gets a unique matched geometry ID like "art-\(album.id)". The same string is used in both AlbumCard and AlbumDetail. SwiftUI pairs them automatically: when one appears and the other disappears in the same animation block, it interpolates between their frames.
  3. Overlay for shared view tree — The detail view is placed via .overlay on the same ScrollView rather than via a NavigationLink or sheet. This is essential: both source and destination must live within the same view tree (same namespace scope) for matchedGeometryEffect to animate.
  4. withAnimation(.spring(response:dampingFraction:)) — Wrapping the state mutation in a spring animation drives the interpolation. SwiftUI's matchedGeometryEffect reads the animation transaction at the moment the state changes — if no animation is active, the transition is instantaneous.
  5. .transition(.identity) on the overlay — Without this, the detail view would also fade in/out on top of the hero motion. Using .identity suppresses the default opacity transition, letting the matched geometry effect own all the motion.

Variants

Grid thumbnail → full-screen photo viewer

struct PhotoGridView: View {
    @Namespace private var photoNS
    @State private var selected: Photo? = nil
    let photos: [Photo] = Photo.samples

    var body: some View {
        ScrollView {
            LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))], spacing: 4) {
                ForEach(photos) { photo in
                    Image(photo.imageName)
                        .resizable()
                        .scaledToFill()
                        .frame(width: 100, height: 100)
                        .clipped()
                        .matchedGeometryEffect(id: photo.id, in: photoNS)
                        .onTapGesture {
                            withAnimation(.spring(duration: 0.4)) { selected = photo }
                        }
                        .accessibilityLabel(photo.caption)
                }
            }
        }
        .overlay {
            if let photo = selected {
                Color.black.ignoresSafeArea()
                    .overlay {
                        Image(photo.imageName)
                            .resizable()
                            .scaledToFit()
                            .matchedGeometryEffect(id: photo.id, in: photoNS)
                    }
                    .onTapGesture {
                        withAnimation(.spring(duration: 0.4)) { selected = nil }
                    }
                    .transition(.identity)
            }
        }
    }
}

Tab-bar indicator pill

matchedGeometryEffect also powers custom segmented controls and animated tab indicators. Place the background capsule in the selected tab only and give every tab's background the same id: "pill" in the same namespace — SwiftUI slides the pill between tabs automatically. No manual offset math required. Pair with .animation(.spring, value: selectedTab) on the container for a one-liner tab animation.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement hero animation in SwiftUI for iOS 17+.
Use matchedGeometryEffect with a shared @Namespace.
Animate a card list → full-screen detail overlay.
Make it accessible (VoiceOver labels, .accessibilityAddTraits(.isModal)).
Add a #Preview with realistic sample data (4 items with titles, subtitles, colors).

In Soarias's Build phase, paste this prompt into the active session after your screens are scaffolded — Claude Code will wire up the namespace, matched IDs, and spring animation in one pass, leaving you to tune durations and swap in your real data model.

Related

FAQ

Does matchedGeometryEffect work on iOS 16?

Yes — matchedGeometryEffect was introduced in iOS 14, so it runs on iOS 16 and earlier without changes. The code on this page targets iOS 17+ because it uses the #Preview macro and .animation API signatures that require iOS 17. The hero animation logic itself is backward-compatible to iOS 14.

Can I use matchedGeometryEffect across NavigationStack push transitions?

Not directly. matchedGeometryEffect requires both views to share the same view tree (same namespace ancestor). Navigation pushes render the destination in a separate view context, breaking the namespace link. The recommended pattern for a hero-into-navigation is either the overlay approach shown above, or using the .navigationTransition(.zoom(sourceID:in:)) modifier introduced in iOS 18 if you can target that OS version.

What is the UIKit equivalent?

In UIKit, hero animations are built with UIViewControllerAnimatedTransitioning and UIViewControllerTransitioningDelegate. You manually snapshot the source view, add it to the transition container, animate its frame to match the destination, then remove the snapshot. This requires ~100 lines of boilerplate compared to SwiftUI's matchedGeometryEffect, which handles frame interpolation, corner-radius animation, and z-ordering automatically.

Last reviewed: 2026-05-12 by the Soarias team.

```