Add iOS 18 Zoom Navigation Transitions To A SwiftUI Grid
The default NavigationStack push is a good baseline. It is predictable, platform-shaped, and boring in the best way. But some interfaces have an obvious source and destination: a photo thumbnail that opens a detail view, a product card that expands into a product page, or a document preview that opens the editor.
In iOS 18, SwiftUI can connect those two views with a zoom navigation transition. You mark the tapped view as the transition source, then ask the destination to zoom from that same source ID.
The ZoomNavigationTransition API is available starting in iOS 18, iPadOS 18, tvOS 18, watchOS 11, and visionOS 2. This grid example is written for iOS and iPadOS. The broader NavigationTransition protocol is available on macOS 15, but the ZoomNavigationTransition type is marked unavailable on macOS in the current SDK, so keep this code in an iOS or iPadOS availability boundary. For iOS 17, the fallback below keeps the same navigation and uses the default push animation.
Model stable items
The source and destination need to agree on an identifier. Use a stable model ID, not an array index. Indexes change when the grid is filtered, sorted, reloaded, or paged, and those are exactly the moments where you want navigation to remain calm.
Here is a tiny model for a grid:
import SwiftUI
struct GalleryItem: Identifiable, Hashable {
let id: UUID
let title: String
let subtitle: String
let symbolName: String
let tint: Color
static func == (lhs: GalleryItem, rhs: GalleryItem) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
static let samples: [GalleryItem] = [
.init(
id: UUID(uuidString: "7B22C6F8-8D78-4C63-85B3-17A645E79F9A")!,
title: "Studio",
subtitle: "12 images",
symbolName: "camera.aperture",
tint: .indigo
),
.init(
id: UUID(uuidString: "C1C3DA82-90E5-4E34-9286-07D62308B861")!,
title: "Sketches",
subtitle: "8 images",
symbolName: "scribble.variable",
tint: .teal
),
.init(
id: UUID(uuidString: "4D79E6B0-4A3A-4A34-B9EA-4B6A6C272C6D")!,
title: "Archive",
subtitle: "31 images",
symbolName: "square.stack.3d.up",
tint: .orange
)
]
}
Those hard-coded UUIDs are only for sample data. In a real app, the ID should come from your database, API response, file URL bookmark, or whatever source already identifies the item.
Build the grid
The namespace is the second part of the match. Create it at a common ancestor of the source and destination. In this example, the GalleryGridView owns the namespace because it contains both the grid links and the destination mapping.
struct GalleryGridView: View {
@Namespace private var zoomNamespace
private let columns = [
GridItem(.adaptive(minimum: 150), spacing: 16)
]
var body: some View {
NavigationStack {
ScrollView {
LazyVGrid(columns: columns, spacing: 16) {
ForEach(GalleryItem.samples) { item in
NavigationLink(value: item) {
GalleryTile(item: item)
.zoomTransitionSource(
id: item.id,
in: zoomNamespace
)
}
.buttonStyle(.plain)
}
}
.padding()
}
.navigationTitle("Gallery")
.navigationDestination(for: GalleryItem.self) { item in
GalleryDetailView(item: item)
.zoomTransitionDestination(
id: item.id,
in: zoomNamespace
)
}
}
}
}
NavigationLink(value:) keeps navigation data-driven. The grid pushes a GalleryItem, and the navigationDestination(for:) closure decides how to render that value. If your production app already uses route enums, use the route’s stable ID for the transition and keep the same pattern.
The .buttonStyle(.plain) is not required for the transition. It just keeps the cell from picking up a default button appearance that may not match a visual grid.
Mark the source and destination
Now add two small view extensions. These are local helper names; the SwiftUI APIs inside them are matchedTransitionSource(id:in:configuration:) and navigationTransition(.zoom(sourceID:in:)). They keep the availability checks out of the main grid code and make the iOS 17 fallback boring on purpose.
extension View {
@ViewBuilder
func zoomTransitionSource<ID: Hashable>(
id: ID,
in namespace: Namespace.ID
) -> some View {
#if os(iOS)
if #available(iOS 18.0, *) {
matchedTransitionSource(id: id, in: namespace) { source in
source
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
.shadow(radius: 8)
}
} else {
self
}
#else
self
#endif
}
@ViewBuilder
func zoomTransitionDestination<ID: Hashable>(
id: ID,
in namespace: Namespace.ID
) -> some View {
#if os(iOS)
if #available(iOS 18.0, *) {
navigationTransition(.zoom(sourceID: id, in: namespace))
} else {
self
}
#else
self
#endif
}
}
The source modifier identifies the view SwiftUI should zoom from. The destination modifier chooses the zoom transition and points at the same id and namespace. If either one is missing, or the IDs do not match, SwiftUI cannot connect the two views, so you should expect ordinary navigation instead of the connected zoom effect.
The configuration closure on matchedTransitionSource is optional, but it is useful when the cell has rounded corners or a shadow. Keep it modest. In the current SDK, the matched transition source configuration supports a focused set of appearance options: rounded rectangle clipping, Color backgrounds, and shadows. Put the real cell styling on the cell itself, then use the configuration to help the transition interpolate the source cleanly.
Draw a cell that can zoom
The cell does not need special transition code. It just needs to have a clear visual shape that can plausibly become the detail screen.
struct GalleryTile: View {
let item: GalleryItem
var body: some View {
VStack(alignment: .leading, spacing: 12) {
ZStack {
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(item.tint.gradient)
Image(systemName: item.symbolName)
.font(.system(size: 42, weight: .semibold))
.foregroundStyle(.white)
}
.aspectRatio(1.2, contentMode: .fit)
VStack(alignment: .leading, spacing: 4) {
Text(item.title)
.font(.headline)
Text(item.subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
.padding(12)
.background(.background, in: RoundedRectangle(cornerRadius: 24))
}
}
Avoid making the source view depend on temporary state that disappears as soon as navigation begins. A grid cell in a lazy grid may be recycled or rebuilt. That is fine when the ID is stable and the cell can be recreated from data. It is fragile if the transition depends on capturing a specific view instance.
Build the detail screen
The detail screen can be a normal SwiftUI view. The transition modifier goes on the destination view returned from navigationDestination.
struct GalleryDetailView: View {
let item: GalleryItem
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
ZStack {
RoundedRectangle(cornerRadius: 28, style: .continuous)
.fill(item.tint.gradient)
Image(systemName: item.symbolName)
.font(.system(size: 84, weight: .semibold))
.foregroundStyle(.white)
}
.aspectRatio(1.15, contentMode: .fit)
VStack(alignment: .leading, spacing: 8) {
Text(item.title)
.font(.largeTitle.bold())
Text(item.subtitle)
.font(.title3)
.foregroundStyle(.secondary)
}
Text("Use this space for the real detail content: metadata, actions, notes, or a larger preview.")
.font(.body)
}
.padding()
}
.navigationTitle(item.title)
.navigationBarTitleDisplayMode(.inline)
}
}
It helps when the source and destination share some visual language: similar artwork, related corner radii, and a destination layout that starts near the top instead of hiding the matching content far down the page. The transition can connect the screens, but layout still carries a lot of the user’s sense of continuity.
Keep the fallback simple
For iOS 17, the extensions above return self. The app still navigates with NavigationStack, still uses stable values, and still lands on the same detail screen. It just uses the system’s default push transition.
That is a good fallback. You can build a custom matchedGeometryEffect version for older systems, but it usually requires a different presentation architecture because matchedGeometryEffect works inside the current view hierarchy, while a navigation push creates a new destination. Unless the transition is central to the product, the default push is easier to maintain.
Common mistakes
The first mistake is using ForEach(items.indices, id: \.self). It works until the collection changes. Prefer model IDs.
The second is creating the namespace inside the cell. Each cell would get its own namespace, so the destination would not be able to find the source. Own the namespace in the view that can see both sides of the navigation.
The third is putting the destination modifier on a child deep inside the detail screen. Apply navigationTransition to the view returned by navigationDestination so the navigation system sees it as the transition style for that destination.
The fourth is overusing the effect. Zoom transitions work best when the source is a meaningful preview of the destination. If every tiny row, settings link, and toolbar button zooms around, the app starts to feel busier instead of clearer.
Practical wrap-up
The iOS 18 zoom transition is a small API with a strict contract: stable source ID, shared namespace, matched source modifier, destination transition modifier. Once those pieces are in place, the rest of the screen can stay ordinary SwiftUI.
Use it where the cell and detail screen are visually connected, keep your iOS 17 fallback plain, and let the model’s identity do the hard work.
Further reading:
- WWDC24 Enhance your UI animations and transitions: https://developer.apple.com/videos/play/wwdc2024/10145/
- Apple documentation: https://developer.apple.com/documentation/swiftui/view/navigationtransition%28_%3A%29
- Apple documentation: https://developer.apple.com/documentation/swiftui/view/matchedtransitionsource%28id%3Ain%3A%29
- Apple documentation: https://developer.apple.com/documentation/swiftui/view/matchedtransitionsource%28id%3Ain%3Aconfiguration%3A%29
- Apple documentation: https://developer.apple.com/documentation/swiftui/navigationtransition/zoom%28sourceid%3Ain%3A%29