A hero animation morphs one view into another through a seamless move, the way a photo grows
from a grid cell into a detail screen. matchedGeometryEffect handles it inside one
hierarchy; matchedTransitionSource plus navigationTransition handle it across a
NavigationStack push.
Matched Geometry Inside One View
Attach matchedGeometryEffect(id:in:) with the same id and @Namespace to the view in both
places. When one insertion and one removal share an id in the same transaction, SwiftUI
interpolates the frames in window space so a single view appears to travel and resize.
@Namespace private var imageEffect
// grid of small images
Image(systemName: image)
.resizable()
.matchedGeometryEffect(id: image, in: imageEffect)
.frame(width: 44, height: 44)
.onTapGesture {
withAnimation {
allImages.removeAll { $0 == image }
selectedImages.append(image)
}
}
// grid of selected images, larger
Image(systemName: image)
.resizable()
.matchedGeometryEffect(id: image, in: imageEffect)
.frame(width: 88, height: 88)
The mutation must run inside withAnimation; without it the views just swap with the default
fade transition. Every matched view needs a unique id within the namespace.
Across a Navigation Push
matchedGeometryEffect does not work between views in a navigation stack. Mark the source
with matchedTransitionSource(id:in:) and give the destination a navigationTransition,
such as .zoom. iOS 18+.
@Namespace private var namespace
NavigationStack {
NavigationLink {
Image(systemName: "globe")
.resizable()
.scaledToFit()
.navigationTransition(.zoom(sourceID: "world", in: namespace))
} label: {
Image(systemName: "globe")
.matchedTransitionSource(id: "world", in: namespace)
}
}
The source id and the transition's sourceID must match, in the same namespace, or the push
falls back to the standard slide.