How to Build a Hero Animation in SwiftUI
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
-
@Namespace private var heroNamespace— Declares a unique animation namespace owned byHeroAnimationView. The namespace is passed down as aNamespace.IDvalue to both the card and the detail, so SwiftUI knows which views to animate between each other. -
Matching IDs with string interpolation — Each album gets a unique matched
geometry ID like
"art-\(album.id)". The same string is used in bothAlbumCardandAlbumDetail. SwiftUI pairs them automatically: when one appears and the other disappears in the same animation block, it interpolates between their frames. -
Overlay for shared view tree — The detail view is placed via
.overlayon the sameScrollViewrather than via aNavigationLinkorsheet. This is essential: both source and destination must live within the same view tree (same namespace scope) formatchedGeometryEffectto animate. -
withAnimation(.spring(response:dampingFraction:))— Wrapping the state mutation in a spring animation drives the interpolation. SwiftUI'smatchedGeometryEffectreads the animation transaction at the moment the state changes — if no animation is active, the transition is instantaneous. -
.transition(.identity)on the overlay — Without this, the detail view would also fade in/out on top of the hero motion. Using.identitysuppresses 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
-
iOS 17 deprecation of
PreviewProvider— Always use the#Previewmacro. Code that compiles withPreviewProviderstill works but triggers deprecation warnings in Xcode 16+ and won't receive new features. -
Source and destination must never coexist simultaneously — If both the
card and the detail are rendered at the same time with the same ID,
matchedGeometryEffectwill log a runtime warning and pick one view to display. Use anif/elseorisSource:parameter to ensure only one view is visible at a time per ID. -
Forgetting
.transition(.identity)on the overlay — The default transition for anifbranch is.opacity. If you omit.transition(.identity)on your detail overlay, the hero move will look correct but you'll also see an unwanted fade-in layered on top of it. -
Accessibility: don't break VoiceOver focus — When the detail overlay
appears, add
.accessibilityAddTraits(.isModal)to it so VoiceOver confines focus inside the detail. Without it, users can accidentally navigate back to the list while the detail is open. -
Mismatched namespace scope — The
@Namespacemust be declared in a common ancestor of both source and destination views. Declaring it inside a child view means the child gets a fresh namespace that the sibling cannot access.
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.