← All shorts
18iOS 18+2 min read

Hero Zoom Push Transitions with navigationTransition(.zoom)

.navigationTransition(.zoom) turns a NavigationStack push into a hero zoom transition with no custom animation code.

Tap a thumbnail, watch it expand into its detail screen. Photos and the App Store do this everywhere.

Getting it yourself used to mean matchedGeometryEffect wired through a manual ZStack toggle. That was fighting NavigationStack instead of using it, and it cost you interactive back-swipe along the way.

#The old way

@Namespace private var ns
@State private var showDetail = false

ZStack {
    if !showDetail {
        thumbnail
            .matchedGeometryEffect(id: "photo", in: ns)
            .onTapGesture { withAnimation { showDetail = true } }
    } else {
        DetailView()
            .matchedGeometryEffect(id: "photo", in: ns)
    }
}

#The new way

struct PhotoGridView: View {
    @Namespace private var namespace
    let photos: [Photo]

    var body: some View {
        NavigationStack {
            ScrollView {
                LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))]) {
                    ForEach(photos) { photo in
                        NavigationLink {
                            PhotoDetailView(photo: photo)
                                .navigationTransition(.zoom(sourceID: photo.id, in: namespace))
                        } label: {
                            Image(photo.thumbnailName)
                                .resizable()
                                .aspectRatio(1, contentMode: .fill)
                        }
                        .matchedTransitionSource(id: photo.id, in: namespace)
                    }
                }
            }
        }
    }
}

struct PhotoDetailView: View {
    let photo: Photo

    var body: some View {
        Image(photo.fullImageName)
            .resizable()
            .scaledToFit()
    }
}

#Why it matters

  • Works with NavigationStack push and pop directly, including interactive back-swipe
  • Source and destination share an ID and a namespace. That's the whole contract: no manual frame or position math
  • Reproduces the system zoom transition Apple uses in Photos and App Store, for free

#Gotcha

The ID passed to .matchedTransitionSource(id:in:) and to .navigationTransition(.zoom(sourceID:in:)) must match exactly. Both views also need the same @Namespace.

The namespace is the half that usually breaks. It gets declared in the parent while the transition is attached to the pushed destination, so it has to be threaded down through every intermediate view. Easy to forget.

Nothing tells you when you miss it. No crash, no warning. The push silently falls back to the default slide animation instead of zooming.

swiftuiios18navigation

Related shorts