SwiftUI Zoom Navigation Transition: Hero Animations with matchedTransitionSource in iOS 26
Build hero zoom animations between a thumbnail and its detail screen using SwiftUI matchedTransitionSource and navigationTransition(.zoom) in iOS 26, with sheets, fullScreenCover, accessibility, and the common reasons it silently falls back to a slide.
The SwiftUI zoom navigation transition in iOS 26 lets a thumbnail expand into a full-screen detail view by pairing .matchedTransitionSource(id:in:) on the source view with .navigationTransition(.zoom(sourceID:in:)) on the destination, no matchedGeometryEffect bookkeeping required. It works inside a NavigationStack, on presented sheets, and on zoom-style fullScreenCovers, and it automatically respects Reduce Motion. This guide covers every modifier, the common "transition not animating" footguns, and how the zoom transition compares to the older matched-geometry approach.
The zoom transition needs three pieces: a @Namespace, .matchedTransitionSource(id:in:) on the source, and .navigationTransition(.zoom(sourceID:in:)) on the destination root view.
Both modifiers must use the same ID and namespace. Mismatches silently fall back to the default push, which is the single biggest reason it "doesn't work."
The zoom transition applies to NavigationLink destinations, .sheet, and .fullScreenCover; programmatic NavigationStack pushes via navigationDestination work too.
Custom transitions adopt the NavigationTransition protocol introduced in iOS 18 and refined in iOS 26. You can compose zoom with your own modifiers.
When Reduce Motion is on, SwiftUI substitutes a cross-fade automatically. You do not need to gate it yourself, but VoiceOver focus should still land on the destination title.
The zoom transition replaces hand-rolled matchedGeometryEffect hero animations for navigation. Keep matchedGeometryEffect for intra-view morphing.
What is the SwiftUI zoom navigation transition?
The zoom navigation transition is a SwiftUI navigation effect, available from iOS 18 and polished in iOS 26, that animates a source view (typically a thumbnail or card) into the position and size of a destination view. Unlike the legacy matchedGeometryEffect approach, which required you to mirror identical geometry on both sides of a transition and toggle it with a boolean, the zoom transition is declarative. You tag a source with an identifier, you tag the destination with the same identifier, and SwiftUI handles the interpolation, the corner-radius matching, the depth shadow, and the interactive dismiss gesture.
What makes this a system transition rather than a custom effect is that it's wired into NavigationStack, .sheet, and .fullScreenCover. The framework knows the visual source and uses that to compute the canonical "Photos app" zoom: the destination expands from the source rect, the source view morphs into the destination's hero element, and a swipe-down or back-swipe reverses the animation along the same path. In iOS 26 this transition also picks up Liquid Glass refraction on the chrome layer, so a navigation bar fading in over the zoomed content blurs the underlying pixels correctly.
The minimum working example
Honestly, the cleanest way to see this work is to just paste a small example into a fresh project. Here's the smallest piece of code that demonstrates the zoom transition inside a NavigationStack. Drop it into a new SwiftUI project, run on an iOS 26 simulator, and tap any cell. You should see the artwork zoom into the detail view.
import SwiftUI
struct Album: Identifiable, Hashable {
let id = UUID()
let title: String
let symbol: String
let tint: Color
}
private let albums: [Album] = [
.init(title: "Midnight Drive", symbol: "moon.stars.fill", tint: .indigo),
.init(title: "Coastal", symbol: "water.waves", tint: .teal),
.init(title: "Neon Skyline", symbol: "building.2.fill", tint: .pink),
]
struct AlbumGrid: View {
@Namespace private var albumNamespace
var body: some View {
NavigationStack {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 140))], spacing: 16) {
ForEach(albums) { album in
NavigationLink(value: album) {
AlbumTile(album: album)
.matchedTransitionSource(id: album.id, in: albumNamespace)
}
.buttonStyle(.plain)
}
}
.padding()
}
.navigationTitle("Albums")
.navigationDestination(for: Album.self) { album in
AlbumDetail(album: album)
.navigationTransition(.zoom(sourceID: album.id, in: albumNamespace))
}
}
}
}
struct AlbumTile: View {
let album: Album
var body: some View {
RoundedRectangle(cornerRadius: 20)
.fill(album.tint.gradient)
.frame(height: 140)
.overlay(Image(systemName: album.symbol).font(.largeTitle).foregroundStyle(.white))
.overlay(alignment: .bottomLeading) {
Text(album.title).font(.headline).foregroundStyle(.white).padding(10)
}
}
}
struct AlbumDetail: View {
let album: Album
var body: some View {
ScrollView {
RoundedRectangle(cornerRadius: 32)
.fill(album.tint.gradient)
.frame(height: 320)
.overlay(Image(systemName: album.symbol).font(.system(size: 96)).foregroundStyle(.white))
.padding()
Text("12 tracks · 47 min").foregroundStyle(.secondary)
}
.navigationTitle(album.title)
}
}
Three things are doing the work here: the @Namespace property that gives both ends a shared coordinate space, the .matchedTransitionSource(id: album.id, in: albumNamespace) on the tile, and the .navigationTransition(.zoom(sourceID: album.id, in: albumNamespace)) on the detail view. The IDs must match. Using album.id on both sides is the most reliable pattern because it's already Hashable and stable across renders.
How do you create a zoom transition in SwiftUI?
To create a zoom transition in SwiftUI, declare a namespace with @Namespace, attach .matchedTransitionSource(id:in:) to the source view (such as a thumbnail or row), and attach .navigationTransition(.zoom(sourceID:in:)) to the destination view's root inside the relevant presentation API. SwiftUI ties them together using the shared namespace and the matching ID, then interpolates the geometry on push and dismiss.
That said, the order in which you write those modifiers matters. .matchedTransitionSource should be the outermost modifier on the source's visual root. If you bury it under a .padding or a .background, SwiftUI will use the padded frame as the zoom source, which is rarely what you want (I lost about an hour to this on my last project before realizing the padding was eating the bounds). On the destination side, .navigationTransition attaches to the root view returned from the navigationDestination closure (or from the sheet's content). Apple's reference covers the modifier in detail in the official NavigationTransition documentation.
You can also drive the navigation programmatically. Using NavigationLink(value:) with a navigationDestination(for:) closure, as in the example above, is the recommended pattern from our SwiftUI NavigationStack type-safe routing guide because it keeps your path type-safe and replayable for deep links. The zoom transition is independent of how you push: a state-driven path push triggers the same animation as a tap-driven NavigationLink.
Zoom transitions on sheets and fullScreenCover
The zoom transition isn't limited to NavigationStack. Both .sheet and .fullScreenCover accept the same .navigationTransition(.zoom(...)) modifier on their content's root view. The presentation context is what differs. A sheet zooms upward into the partial-height card, and a fullScreenCover zooms into the full screen with a heavier shadow.
struct PhotoGrid: View {
@Namespace private var ns
@State private var selected: Photo?
var body: some View {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 120))]) {
ForEach(photos) { photo in
Button { selected = photo } label: {
PhotoThumb(photo: photo)
.matchedTransitionSource(id: photo.id, in: ns)
}
}
}
}
.fullScreenCover(item: $selected) { photo in
PhotoViewer(photo: photo)
.navigationTransition(.zoom(sourceID: photo.id, in: ns))
}
}
}
A few presentation specifics worth knowing. With .sheet, the interactive dismiss gesture (drag the sheet down) reverses the zoom along the same path back to the source rect. With .fullScreenCover, you get the edge-swipe back gesture for free, but only if you also add .toolbar(.hidden, for: .navigationBar) on the cover content, or wrap it in a NavigationStack. Without that, the back-swipe is intercepted by the parent navigation.
matchedTransitionSource vs. matchedGeometryEffect
These two APIs solve different problems, and the naming similarity causes a lot of confusion. Short version: matchedGeometryEffect morphs a view between two states in the same view tree; matchedTransitionSource bridges two different presentation contexts (a source view and a navigation destination) and is the supported way to do hero animations across pushes, sheets, and covers in iOS 18+/26.
Dimension
matchedTransitionSource + .zoom
matchedGeometryEffect
Minimum OS
iOS 18 / iPadOS 18 / macOS 15
iOS 14+
Use case
Navigation push, sheet, fullScreenCover
State-driven view morphing inside one tree
State management
Driven by the navigation system itself
You toggle a boolean and rebuild the view
Reduce Motion handling
Automatic cross-fade fallback
You handle it manually
Interactive dismiss
Built-in (back-swipe, sheet drag)
You implement the gesture and reverse animation
Liquid Glass chrome
Refraction over zoomed content works correctly
You must coordinate material layers yourself
Cross-cell identity
Stable via the namespace's source ID
Stable, but requires the same view to exist on both sides
Choose matchedTransitionSource whenever you're animating from a thumbnail into a detail screen. Keep matchedGeometryEffect for in-place transformations, for instance expanding an inline card into a larger card within the same scroll view, or morphing a play button into a pause button. The two compose: you can have a matched-geometry icon swap inside a detail view that was itself reached via a zoom transition. For deeper animation patterns I cover both in the SwiftUI animations guide covering springs and keyframes.
Why isn't my SwiftUI zoom transition working?
If the destination pushes with the default slide animation instead of the zoom, the framework couldn't reconcile the source and destination IDs. Walk this checklist in order. The first three items account for the vast majority of "not working" reports I see on the developer forums (and were also the ones I hit shipping a photos app earlier this year).
Namespace identity mismatch. The @Namespace must be the same instance on both sides. If you declared one in the parent and another in the child, they won't reconcile. Pass the namespace down via a parameter if the source and destination live in different files.
ID type mismatch. The IDs must be Hashable and equal. Using album.id on one side and album.title on the other will silently fail.
Missing modifier on the destination root..navigationTransition must be on the topmost view returned from the destination closure, not buried inside a NavigationStack or behind a VStack.
Source view is offscreen at present time. If the source row has been scrolled out of view when the navigation push fires (common with programmatic pushes after a delay), SwiftUI has no rect to zoom from and falls back to a cross-fade.
Wrapping in a non-navigation container. Putting .navigationTransition on the content of a plain .popover or a custom modal does nothing. Only the three supported presentation APIs honor it.
iOS version below 18. Older deployment targets compile the modifier (it's annotated with availability) but the runtime ignores it. Gate calls with if #available(iOS 18, *) and provide a fallback push.
Accessibility, Reduce Motion, and VoiceOver
Accessibility is woven into this API rather than appended. When the user has Reduce Motion enabled in Settings, SwiftUI swaps the zoom for a quick cross-fade automatically; you don't need to read UIAccessibility.isReduceMotionEnabled or build a fallback path. That's the correct default, because users who enable Reduce Motion are explicitly opting out of large positional animations, and a 250-millisecond opacity transition is the substitute recommended by the WCAG 2.2 Animation from Interactions guidance.
VoiceOver behavior is where you still have work to do. After a zoom push, focus should land on the destination's most semantically significant element (usually the title) and announce the navigation. By default NavigationStack moves focus to the back button, which is technically correct but rarely what users expect on a hero-style detail screen. Override it with .accessibilityFocused tied to an onAppear trigger, or use accessibilityPostNotification(.screenChanged, argument: …) when the destination becomes visible. I dig into the patterns in the SwiftUI accessibility guide for VoiceOver and Dynamic Type, but the short version is: if you took the trouble to design a hero animation, take the trouble to land VoiceOver focus on the same hero element.
Two more accessibility considerations. First, the source view should carry an .accessibilityLabel that describes what will open ("Album, Midnight Drive, opens detail"), because the visual affordance of a tappable card is invisible to VoiceOver. Second, when the source disappears during the zoom (it shrinks and fades), make sure the destination's hero element isn't hidden from accessibility by a stale .accessibilityHidden(true) from your scroll-position bookkeeping.
Custom NavigationTransition protocol
Beyond the built-in .zoom, iOS 18 introduced the NavigationTransition protocol, and iOS 26 added the helpers needed to compose your own. The protocol requires a single method, body(content:phase:), where phase is one of .identity, .willAppear, or .willDisappear. You return a modified content view that interpolates between those phases.
The phase enum is what enables the symmetry you want for push and pop. .willAppear is the destination's "before pushed" state, and .willDisappear is its "after popped" state. SwiftUI interpolates between .willAppear → .identity on push and .identity → .willDisappear on pop, using your view's animatable properties. You can compose this with the built-in .zoom by chaining transitions in a custom container, though Apple's recommendation in the WWDC 2024 navigation enhancements session is to lean on the system transitions and reserve custom ones for genuinely novel effects.
Performance and image sizing
The zoom transition is GPU-accelerated and cheap for vector content. Where it gets expensive is with large bitmap images where the source thumbnail is a downsampled version and the destination loads a full-resolution asset. If the destination image is still loading when the zoom fires, you'll see a momentary blurred-up source pixel grid before the high-res image swaps in.
Two patterns avoid this. First, present a placeholder in the destination that is the source view's image. Using .matchedGeometryEffect on the image alone within the destination, you can fade in the high-res version on top. Second, preload the destination image when the user begins the interaction; SwiftUI's .task(priority: .userInitiated) on the destination root will start fetching before the animation completes, and AsyncImage with a .transition(.opacity) on the success state covers the seam.
Frequently Asked Questions
Does the zoom transition work on iOS 17 or earlier?
No. .navigationTransition(.zoom(...)) and .matchedTransitionSource require iOS 18 or later, with iOS 26 adding Liquid Glass-aware chrome handling. On older targets, gate the call with if #available(iOS 18, *) and let the default slide push run as the fallback.
Can I use the zoom transition with TabView?
You can use it for navigation pushes within a tab. Each tab typically owns a NavigationStack, and the zoom transition fires on pushes inside that stack. The tab switch itself uses its own crossfade animation and is not customizable with NavigationTransition.
Why does my source view shrink to zero before zooming?
That happens when the source view is removed from the hierarchy before the transition completes, usually because the source list re-renders during the push. Pin the source identity with a stable id on the row, or attach .matchedTransitionSource to a view that survives the re-render (often a parent RoundedRectangle rather than the dynamic image).
Is matchedTransitionSource a replacement for matchedGeometryEffect?
Only for navigation hero animations. matchedTransitionSource bridges two presentation contexts (source view and pushed destination), while matchedGeometryEffect morphs views inside a single tree on a state change. Use them together, not against each other.
Does the zoom transition support custom corner-radius matching?
Yes. SwiftUI inspects the source view's clipped shape and interpolates the corner radius into the destination's shape over the course of the zoom. If you want a specific final radius, clip the destination's hero element with .clipShape(RoundedRectangle(cornerRadius: 32)) and SwiftUI will animate from the source's smaller radius to that value.
How to use SwiftUI PhotosPicker in iOS 26 for multi-select, video filtering, and file-based loading. Covers race conditions, iCloud progress, and VoiceOver.
Swift RegexBuilder gives you type-safe regular expressions with named captures, inline transforms, and Unicode-correct matching by default. Learn the DSL, compare it to NSRegularExpression, and avoid production traps.
Build SwiftUI Metal shaders in iOS 26 with colorEffect, distortionEffect, and layerEffect. Includes MSL setup, TimelineView animation, and GPU cost tips with working code.