← 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.

Making a tapped thumbnail visually expand into its detail screen — the effect Photos and App Store use everywhere — used to mean matchedGeometryEffect wired through a manual ZStack toggle, fighting NavigationStack instead of using it, and losing interactive back-swipe in the process.

#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 only need to share an ID and a namespace — 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, and both views need the same @Namespace. Since the namespace is declared in the parent but the transition is attached to the pushed destination, it's easy to forget to thread it down through intermediate views — when that happens there's no crash or warning, the push just silently falls back to the default slide animation instead of zooming.

swiftuiios18navigation

Related shorts