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.

Updated: August 15, 2026

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.

The API signature, parameter by parameter

Apple's SwiftUI documentation for matchedGeometryEffect lists five parameters. Every one of them matters for a hero animation that behaves correctly.

  • 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.
  • Serious/productivity morph: .spring(response: 0.35, dampingFraction: 0.95). Nearly critically damped. Fast, no bounce, feels "professional."
  • 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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. You crossed a NavigationStack boundary. Pushed destinations don't inherit the parent's namespace. Use matchedTransitionSource + .navigationTransition(.zoom) instead. See the zoom navigation transition guide with matchedTransitionSource in iOS 26 for the API and setup.

matchedGeometryEffect vs. matchedTransitionSource

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.

DimensionmatchedGeometryEffectmatchedTransitionSource
IntroducediOS 14 (SwiftUI 2)iOS 18
ScopeAny view morph inside a single view treeNavigation push into a pushed destination
Requires shared namespaceYes, via @NamespaceYes, via @Namespace
Crosses NavigationStackNoYes (this is the whole point)
Crosses sheet/popoverYes (both views in the same host)No
Animation controlFull, any Animation via withAnimationCurated .zoom transition
Right forCard expand, tag reflow, list-to-detail without pushGrid-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.

// Grid cell
VStack(alignment: .leading, spacing: 4) {
    RoundedRectangle(cornerRadius: 12).fill(article.cover)
        .frame(height: 100)
        .matchedGeometryEffect(id: "cover-\(article.id)", in: space)
    Text(article.title).font(.caption)
        .matchedGeometryEffect(id: "title-\(article.id)", in: space)
}

// Detail
VStack(alignment: .leading, spacing: 16) {
    RoundedRectangle(cornerRadius: 24).fill(article.cover)
        .frame(height: 320)
        .matchedGeometryEffect(id: "cover-\(article.id)", in: space, isSource: false)
    Text(article.title).font(.largeTitle.bold())
        .matchedGeometryEffect(id: "title-\(article.id)", in: space, isSource: false)
}

Staggered timing

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:

@Environment(\.accessibilityReduceMotion) private var reduceMotion

var heroAnimation: Animation {
    reduceMotion ? .linear(duration: 0.01) : .spring(response: 0.42, dampingFraction: 0.82)
}

// Usage
withAnimation(heroAnimation) {
    selected = photo
}

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.

Diana Kowalski
About the Author Diana Kowalski

Mobile UX engineer translating design intent into pixel-perfect SwiftUI. Has strong opinions about haptics.