Matched Transitions in SwiftUI

Matched Geometry and the Zoom navigation transition

published on September 23, 2025

Unarguably, one of the biggest advantages of SwiftUI is the ability to create complex UIs and UI interactions with little to no effort. One tool a developer's Swiss Army knife is the matched view effects. In this post, we are going to dive into how matchedGeometryEffect and navigationTransition can be used to easily elevate your UI to the next level.

matchedGeometryEffect

This modifier is used to synchronise the geometry of two views on the UI, creating the illusion that the two views are the same. Or in other words, when one view is removed and an other is added to the hierarchy, the inserted view will appear identical to the removed one animating to its new position instead of fading in directly at the target location.

The matchedGeometryEffect modifier is useful when major changes need to happen to the UI based on some state change, that alters the hierarchy’s structure to such complexity that would not be possible using only one view. Let’s see an example:

struct SomeView: View {
    @Namespace private var namespace
    @State private var open = false
    
    var body: some View {
        VStack {
            HStack {
                Text("Some content")
                if !open {
                    RoundedRectangle(cornerRadius: 10)
                        .fill(.red)
                        .frame(width: 100, height: 100)
                        .onTapGesture {
                            withAnimation {
                                self.open.toggle()
                            }
                        }
                }
            }
            if open {
                RoundedRectangle(cornerRadius: 10)
                    .fill(.blue)
                    .frame(width: 200, height: 200)
                    .onTapGesture {
                        withAnimation {
                            self.open.toggle()
                        }
                    }
            }
        }
    }
}

If you run this code and click the red rectangle, you will see that it fades out and the bigger blue one fades in.

Animation without matched geometry

It’s ok but we can do better, let’s see how

struct SomeView: View {
    @Namespace private var namespace
    @State private var open = false
    
    var body: some View {
        VStack {
            HStack {
                Text("Some content")
                if !open {
                    RoundedRectangle(cornerRadius: 10)
                        .fill(.red)
                        .matchedGeometryEffect(id: "shape", in: namespace) // new
                        .frame(width: 100, height: 100)
                        .onTapGesture {
                            withAnimation {
                                self.open.toggle()
                            }
                        }
                }
            }
            if open {
                RoundedRectangle(cornerRadius: 10)
                    .fill(.blue)
                    .matchedGeometryEffect(id: "shape", in: namespace) // new
                    .frame(width: 200, height: 200)
                    .onTapGesture {
                        withAnimation {
                            self.open.toggle()
                        }
                    }
            }
        }
    }
}

Animation without matched geometry

As you can see, all we need to do is to create a namespace, and attach the right modifier to the view. It’s important to use the same id and namespace for both of the views, otherwise the system will not know how to handle your hierarchy.

❗️Also note that the id should not appear in more views, otherwise the result will be undefined

navigationTransition

matchedGeometryEffect is a truly powerful tool, but it can’t be used for navigation between screens in a NavigationStack. For that we have navigationTransition and its corresponding matchedTransitionSource modifier.

At the time of writing this article, the only option for the navigation transition is zoom. Let’s see how it works

struct ContentView: View {
    @Namespace private var namespace
    
    var body: some View {
        NavigationStack {
            List {
                ForEach(0..<10) { i in
                    NavigationLink {
                        Destination()
                    } label: {
                        HStack {
                            Text("#\(i)")
                        }
                    }
                }
            }
        }
    }
}

Regular navigation transition

As you can see, the regular push navigation is used, not let’s set up the zoom animation for our items:

struct ContentView: View {
    @Namespace private var namespace
    
    var body: some View {
        NavigationStack {
            List {
                ForEach(0..<10) { i in
                    NavigationLink {
                        Destination()
                            .navigationTransition(.zoom(sourceID: "zoom\(i)", in: namespace))
                    } label: {
                        HStack {
                            Text("#\(i)")
                        }
                        .matchedTransitionSource(id: "zoom\(i)",
                                                 in: namespace)
                    }
                }
            }
        }
    }
}

Zoom transition

Follow me on X