SwiftUI matchedGeometryEffect: The Complete Guide to Hero Animations and Namespace Morphing
SwiftUI matchedGeometryEffect morphs a view's frame between two states via a shared @Namespace. Learn the API, spring tuning, and hero animation patterns for iOS 26.
SwiftUI matchedGeometryEffect is a view modifier that morphs a view's frame, position, and size between two states by sharing a geometry identifier inside a common @Namespace. When one view disappears and another with the same id and namespace appears in the same animation transaction, SwiftUI interpolates their geometry, producing the "hero animation" effect where a thumbnail expands into a detail view or a tag flies from one row to another. It shipped in iOS 14, works everywhere SwiftUI runs (iPhone, iPad, Mac, watchOS, visionOS), and in iOS 26 it still handles every hero pattern that doesn't cross a NavigationStack boundary.
matchedGeometryEffect needs three things: a stable id, a shared @Namespace, and both views existing in the view tree at the same moment (usually via if/else in a ZStack with an .animation() wrapper).
The default properties: .frame matches both position and size. Use .position alone when you want the source's size preserved and only the origin to morph.
Spring animations tuned around response: 0.42, dampingFraction: 0.82 feel right for hero morphs. Linear or easeInOut feel wrong on physical size changes.
Only one view per id should have isSource: true at any moment. Two sources, or zero sources, means the morph silently breaks.
Prefer matchedTransitionSource + .navigationTransition(.zoom) when the animation crosses a NavigationStack push (iOS 18+); matchedGeometryEffect stays the right tool inside a single view.
Wrap the state change and the pair of views in the same withAnimation block, or the interpolation degrades to a fade.
What is matchedGeometryEffect in SwiftUI?
matchedGeometryEffect(id:in:properties:anchor:isSource:) synchronizes the geometry of a view with a "source" view that shares the same id inside the same Namespace.ID. When SwiftUI applies the modifier during a render, it looks up the source's frame in the namespace, then applies whatever geometric transform is needed to move and/or resize the target so its resolved frame matches. During an animated transaction, this transform interpolates, producing the physical morph.
Honestly, the mental model that keeps me sane is this: the target view is drawn wherever it naturally lands in its parent's layout, then a hidden GeometryEffect nudges it toward the source's frame. Layout doesn't change. The parent still thinks the view is at its declared position. Only the visual rendering shifts. That's why a matched view can "hero" from a grid cell to a full-screen detail without breaking the surrounding LazyVGrid: the grid never learns the cell escaped.
Because it's a geometry transform (not a navigation primitive), matchedGeometryEffect works inside any container: ZStack, VStack, sheets, popovers, custom modal presentations. It does not, however, cross a NavigationStack push automatically. The pushed view lives in a different presentation host and won't see the parent's namespace. For that case, iOS 18 introduced matchedTransitionSource, which we'll compare below.
Minimum viable example: photo grid to detail
So, here's the shortest complete example that actually demonstrates the morph. Paste it into a fresh SwiftUI file and run it in the iOS 26 simulator. Tap any thumbnail to expand, then tap the expanded card to dismiss.
import SwiftUI
struct HeroGrid: View {
@Namespace private var heroSpace
@State private var selected: Photo? = nil
let photos: [Photo] = Photo.samples
var body: some View {
ZStack {
// Grid state (source of the morph)
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))], spacing: 8) {
ForEach(photos) { photo in
RoundedRectangle(cornerRadius: 12)
.fill(photo.color)
.frame(height: 100)
.matchedGeometryEffect(
id: photo.id,
in: heroSpace,
isSource: selected != photo
)
.onTapGesture {
withAnimation(.spring(response: 0.42, dampingFraction: 0.82)) {
selected = photo
}
}
}
}
.padding()
}
// Detail state (target of the morph)
if let photo = selected {
RoundedRectangle(cornerRadius: 28)
.fill(photo.color)
.matchedGeometryEffect(
id: photo.id,
in: heroSpace,
isSource: false
)
.frame(maxWidth: .infinity, maxHeight: 420)
.padding()
.onTapGesture {
withAnimation(.spring(response: 0.42, dampingFraction: 0.82)) {
selected = nil
}
}
}
}
}
}
struct Photo: Identifiable, Equatable {
let id: UUID
let color: Color
static let samples: [Photo] = (0..<12).map { i in
Photo(id: UUID(), color: [.pink, .mint, .indigo, .orange][i % 4])
}
}
Two things carry the morph. First, both the grid cell and the detail card share the same photo.id inside heroSpace. Second, isSource flips based on selection state. When a photo is selected the grid cell is not the source, so its geometry is pulled toward the detail card's frame during the transition. Reverse when dismissing.
id: some Hashable: the geometry key. Must be stable across state changes. Using a random UUID per render is the #1 bug I see in code review; use a model id, an enum case, or a compile-time string.
in: Namespace.ID: the namespace declared with @Namespace. Both matched views must share this same namespace instance, which is why hero animations almost always live inside a single container view that owns the @Namespace.
properties: MatchedGeometryProperties: an option set of .position, .size, or .frame (both). Default is .frame.
anchor: UnitPoint: where in the target view the source's position is applied. Default is .center. Use .topLeading when morphing into a card with headline content anchored to the top-left.
isSource: Bool: when true, this view publishes its geometry into the namespace. When false, it consumes geometry from whichever source view currently owns the id. Default is true. The critical rule: exactly one view per id must be the source at any moment.
properties: frame vs. position vs. size
The properties parameter is where you tell SwiftUI which parts of the geometry to sync. Ninety percent of hero animations use the default (.frame) and it just works. The other ten percent need deliberate control.
Use .frame (default) when the source and target render at different sizes and you want the target to physically morph, like a thumbnail to a detail card, a small avatar to a header portrait, or a compact chip to an expanded pill. This is the canonical hero animation.
Use .position alone when both views render at the same size but at different locations, and you want the target to fly in from where the source used to be without stretching. Think of a tag pill jumping from a "suggested" row to a "selected" row: same pill shape, different address in the layout.
Use .size alone almost never. It syncs only the width and height, letting layout decide position. This is useful when both views live in different parents but you want them to render at identical sizes. For instance, a search bar in a nav header morphing to match a search bar in a sheet, where each parent independently positions its bar.
// Position-only morph: tags fly between rows without stretching
Text(tag.label)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Capsule().fill(.tint.opacity(0.15)))
.matchedGeometryEffect(
id: tag.id,
in: tagSpace,
properties: .position // size stays intrinsic to the label
)
Anchor: pinning the morph origin
The anchor parameter tells SwiftUI which point inside the target should align with the source's center. Default is .center, which is right for symmetric morphs. It becomes visible (and important) the moment your target isn't the same shape as your source.
Say you're morphing a 60×60 avatar into a 320×80 header pill. With the default center anchor, the pill's center lands where the avatar's center was, and the pill's leading edge extends left of the original avatar. If you actually want the pill to grow rightward from the avatar's position, anchor to .leading so the pill's leading edge lines up with the source's center. That's the difference between a morph that looks physical and one that looks "wrong but I can't put my finger on why."
Image(user.avatar)
.resizable()
.frame(width: 320, height: 80)
.matchedGeometryEffect(
id: user.id,
in: profileSpace,
anchor: .leading // grow rightward from avatar's center
)
Spring tuning for hero motion
Hero animations live and die by the spring. Linear timing on a physical size change reads as "software resizing a window"; a well-tuned spring reads as "an object I picked up." My defaults, arrived at after too many redlines with designers:
Standard hero morph: .spring(response: 0.42, dampingFraction: 0.82). Snappy, minimal overshoot. Feels right for thumbnails-to-detail on iPhone.
Playful morph (game UI, marketing app): .spring(response: 0.55, dampingFraction: 0.68). More bounce, longer travel. Only ship this when the design system explicitly calls for it.
Large-frame morph on iPad/Mac: bump response to 0.5–0.6. Larger travel distances need more time, or the motion feels twitchy.
Pair the morph with a light haptic on the tap that triggers it. Our SwiftUI Haptics in iOS 26 guide covers .sensoryFeedback(.impact(weight: .light), trigger: selected), which is the right texture. Heavier haptics compete with the visual morph instead of complementing it.
If you want to layer secondary motion on top of the morph (a caption fading in, an icon rotating), use the same spring for both. Different curves running in parallel is the fastest way to make an interface feel amateur. When you need to compose more elaborate sequences, the SwiftUI Animations guide covering springs, keyframes, and transitions shows how to structure them without ending up with mismatched timing.
Why isn't my matchedGeometryEffect working?
Ranked by how often they hit me in code review:
Both views aren't in the tree at the same moment. If the source is inside a branch that's fully removed before the target appears, there's no geometry to interpolate from and you get a fade. Fix: keep both views in the tree (typically inside a ZStack), and toggle visibility via isSource and opacity rather than if/else.
The id changed between renders. If you pass id: UUID(), every render generates a new key and SwiftUI can't correlate the two views. Use a model id, not a fresh UUID.
The state change isn't inside withAnimation. The modifier interpolates within an animated transaction. Change the driving state outside withAnimation and you get a hard cut. I hit this exact bug shipping a photo picker last spring; once I saw it, the fix took ten seconds.
Two views claim isSource: true at the same time. SwiftUI picks one and silently ignores the other. Gate isSource on your selection state so exactly one is true at any moment.
The @Namespace is redeclared per render. Declaring @Namespace inside a computed subview means each render owns a different namespace instance. Declare it in the top-level view that contains both matched views.
The source is inside a LazyVStack or LazyHGrid and got recycled. Lazy containers may unload off-screen cells. When the source disappears, the target has nothing to morph from. Use non-lazy containers if the source can scroll off-screen during the transition, or delay the state change until the source is on-screen.
These two APIs solve overlapping problems, and the split is worth naming explicitly, because Apple's WWDC24 session on enhancing app design with SwiftUI introduced matchedTransitionSource without cleanly deprecating anything.
Dimension
matchedGeometryEffect
matchedTransitionSource
Introduced
iOS 14 (SwiftUI 2)
iOS 18
Scope
Any view morph inside a single view tree
Navigation push into a pushed destination
Requires shared namespace
Yes, via @Namespace
Yes, via @Namespace
Crosses NavigationStack
No
Yes (this is the whole point)
Crosses sheet/popover
Yes (both views in the same host)
No
Animation control
Full, any Animation via withAnimation
Curated .zoom transition
Right for
Card expand, tag reflow, list-to-detail without push
Grid-to-pushed-detail hero, tab-to-detail hero
Rule of thumb: if the tap results in a NavigationLink push, use matchedTransitionSource. If the tap results in a state change inside the same view (expanding an inline card, revealing a modal, morphing a chip), use matchedGeometryEffect. They can coexist. A photo grid can use matchedGeometryEffect for inline zoom and matchedTransitionSource for the "open in editor" push.
Advanced patterns: staggered morphs and multi-element heroes
Beyond the single-element morph, three patterns show up regularly in production apps.
Multi-element hero
You want the thumbnail, its title, and its author avatar all to morph into their detail positions simultaneously. Assign each element its own id inside the same namespace and match them independently. Because SwiftUI interpolates every matched pair inside the same transaction, they animate together.
If you want the cover to morph first and the title to follow slightly behind, wrap the second element's animation in a delayed transaction. In iOS 26 the cleanest way is withAnimation(.spring(response: 0.42, dampingFraction: 0.82).delay(0.06)) { … } around the title state change, but note that this only works if the title drives a state change, not just the matched id lookup. For pure geometry stagger, the animation(_:value:) modifier with different value triggers per element gives you per-element timing without splitting the state.
Grid reflow with matched cells
Removing an item from a grid and having remaining cells slide to fill its slot is a matched-geometry-effect problem, not a transition problem. Assign each cell its id, wrap the array mutation in withAnimation, and SwiftUI will interpolate every remaining cell's new position from its old one. That's how the Apple Photos app reflows on delete.
Accessibility and Reduce Motion
Hero animations are motion, and roughly 3% of iOS users have Reduce Motion enabled. matchedGeometryEffect respects the transaction it runs in, so if you swap the spring for .linear(duration: 0) when Reduce Motion is on, the morph becomes an instant cut and no vestibular signal is emitted. Read the setting via @Environment(\.accessibilityReduceMotion) and gate the animation:
Don't remove the state change entirely when Reduce Motion is on; the user still needs to see the detail view. Just remove the animated transition into it. This is the exact pattern the system frameworks (like Apple's Human Interface Guidelines on motion) recommend for third-party apps.
Performance and render cost
A matchedGeometryEffect modifier costs one geometry lookup per render per matched view, essentially free. What is not free is animating large views: a full-screen image morphing frame-by-frame at 120Hz is drawing many megapixels per frame. On iPhone 15 Pro and later this is fine at 1x images. On older devices, or with 3x images loaded from disk without downsampling, you'll drop frames.
Two mitigations. First, make sure the image you're morphing is already sized correctly. Don't feed a 4000×3000 photo into a modifier that renders it at 320×80. Downsample first. Second, when morphing complex hierarchies, apply .drawingGroup() to the container so SwiftUI rasterizes it once and interpolates the raster. That turns 40 subview renders per frame into 1. Use sparingly, though; drawingGroup() disables SwiftUI-native effects like accessibility inspection.
Frequently Asked Questions
Can matchedGeometryEffect work across NavigationLink pushes?
No, not directly. Pushed destinations render in a separate presentation host and don't inherit the parent view's namespace. For hero animations that cross a NavigationStack push, use matchedTransitionSource paired with .navigationTransition(.zoom), which was introduced in iOS 18 specifically for this case.
Why does my matchedGeometryEffect just fade instead of morphing?
The most common cause is that only one of the two matched views exists in the view tree at a time. Keep both views mounted (typically in a ZStack where one has isSource: false) and toggle their visibility via opacity or isSource, not if/else. Also confirm the state change is wrapped in withAnimation.
What's the best spring for a matchedGeometryEffect hero animation?
Start with .spring(response: 0.42, dampingFraction: 0.82). It's snappy enough to feel responsive and damped enough to avoid perceptible overshoot on physical size changes. Tune response upward for larger travel distances on iPad and Mac (0.5–0.6), and dampingFraction upward toward 0.95 for productivity apps where bounce would feel unprofessional.
Do I need one @Namespace per animation or can I reuse one?
One namespace per view that owns a group of matched pairs. All matched ids inside a namespace share a single geometry table, so you can host as many independent hero pairs in a namespace as you want as long as their ids are unique. Reuse across unrelated views only if the ids can't collide.
Does matchedGeometryEffect work with LazyVStack and LazyVGrid?
Yes, with a caveat: lazy containers unload off-screen cells. If the source cell scrolls out of view during the transition, the morph loses its source and degrades. For hero animations that trigger while the source is on-screen (the typical case), lazy containers work fine. If the animation might outlive scroll, use a non-lazy container or delay the state change until the source is visible.
A practical guide to Apple's unified logging in Swift for iOS 26: how Logger replaces print, when to use each log level, how privacy annotations protect PII, and how OSLogStore and OSSignposter power in-app diagnostics and performance tracing.
SwiftUI's PhaseAnimator walks a view through ordered animation phases from a single trigger, replacing @State flags and asyncAfter chains. Learn per-phase curves, trigger patterns, and how to combine it with CustomAnimation, plus real-world code for like buttons and toasts.
Profile SwiftUI apps with Xcode Instruments in Xcode 26. A real-device workflow for finding hangs, frame drops, and memory leaks, plus custom signposts with OSSignposter.