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: